From 9a16b7b0f661bf64076433d56133c56a7811918b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 22 Aug 2019 08:58:41 +0200 Subject: [PATCH 001/262] Update azure-pipelines-release.yml for Azure Pipelines (#26128) * Update azure-pipelines-release.yml for Azure Pipelines * Update azure-pipelines-release.yml --- azure-pipelines-release.yml | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 81bb1944bed..d0cfc294db1 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -7,7 +7,7 @@ trigger: pr: none variables: - name: versionBuilder - value: '5.2' + value: '6.1' - group: docker - group: github - group: twine @@ -155,48 +155,46 @@ stages: vmImage: 'ubuntu-latest' steps: - script: | - echo '{ "experimental": true }' | sudo tee /etc/docker/daemon.json - sudo service docker restart + mkdir -p ~/.docker + echo '{ "experimental": "enabled" }' > .docker/config.json - sleep 15 sudo docker login -u $(dockerUser) -p $(dockerPassword) displayName: 'Enable manifest / Docker login' - script: | set -e - export DOCKER_CLI_EXPERIMENTAL=enabled function create_manifest() { local tag_l=$1 local tag_r=$2 - sudo docker manifest create homeassistant/home-assistant:${tag_l} \ + sudo docker --config .docker manifest create homeassistant/home-assistant:${tag_l} \ homeassistant/amd64-homeassistant:${tag_r} \ homeassistant/i386-homeassistant:${tag_r} \ homeassistant/armhf-homeassistant:${tag_r} \ homeassistant/armv7-homeassistant:${tag_r} \ homeassistant/aarch64-homeassistant:${tag_r} - sudo docker manifest annotate homeassistant/home-assistant:${tag_l} \ + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ homeassistant/amd64-homeassistant:${tag_r} \ --os linux --arch amd64 - sudo docker manifest annotate homeassistant/home-assistant:${tag_l} \ + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ homeassistant/i386-homeassistant:${tag_r} \ --os linux --arch i386 - sudo docker manifest annotate homeassistant/home-assistant:${tag_l} \ + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ homeassistant/armhf-homeassistant:${tag_r} \ --os linux --arch arm --variant=v6 - sudo docker manifest annotate homeassistant/home-assistant:${tag_l} \ + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ homeassistant/armv7-homeassistant:${tag_r} \ --os linux --arch arm --variant=v7 - sudo docker manifest annotate homeassistant/home-assistant:${tag_l} \ + sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ homeassistant/aarch64-homeassistant:${tag_r} \ --os linux --arch arm64 --variant=v8 - sudo docker manifest push --purge homeassistant/home-assistant:${tag_l} + sudo docker --config .docker manifest push --purge homeassistant/home-assistant:${tag_l} } # Create version tag From b3ae6a20ba12821cab366dac9fc44be7d66c455f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 22 Aug 2019 09:28:46 +0200 Subject: [PATCH 002/262] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index d0cfc294db1..7409be5f98c 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -180,7 +180,7 @@ stages: sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ homeassistant/i386-homeassistant:${tag_r} \ - --os linux --arch i386 + --os linux --arch 386 sudo docker --config .docker manifest annotate homeassistant/home-assistant:${tag_l} \ homeassistant/armhf-homeassistant:${tag_r} \ From f793c71f5260d31619807dd9980b21a361bc9789 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 22 Aug 2019 17:34:54 +0200 Subject: [PATCH 003/262] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 7409be5f98c..44d910a8106 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -197,6 +197,12 @@ stages: sudo docker --config .docker manifest push --purge homeassistant/home-assistant:${tag_l} } + sudo docker pull homeassistant/amd64-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/i368-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/armhf-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/armv7-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/aarch64-homeassistant:$(Build.SourceBranchName) + # Create version tag create_manifest "$(Build.SourceBranchName)" "$(Build.SourceBranchName)" @@ -205,6 +211,7 @@ stages: create_manifest "dev" "$(Build.SourceBranchName)" elif [[ "$version" =~ b ]]; then create_manifest "beta" "$(Build.SourceBranchName)" + create_manifest "rc" "$(Build.SourceBranchName)" else create_manifest "stable" "$(Build.SourceBranchName)" create_manifest "latest" "$(Build.SourceBranchName)" From 2b78bfaf78ae225a7ca24f31e40ec6d67ca8d879 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 22 Aug 2019 17:47:35 +0200 Subject: [PATCH 004/262] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 44d910a8106..2ad13288e08 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -215,6 +215,7 @@ stages: else create_manifest "stable" "$(Build.SourceBranchName)" create_manifest "latest" "$(Build.SourceBranchName)" + create_manifest "beta" "$(Build.SourceBranchName)" fi displayName: 'Create Meta-Image' From be0739626b4f3b9cb763fca2ff306b7e22a708d7 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 22 Aug 2019 17:49:17 +0200 Subject: [PATCH 005/262] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 2ad13288e08..6b986329291 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -216,6 +216,7 @@ stages: create_manifest "stable" "$(Build.SourceBranchName)" create_manifest "latest" "$(Build.SourceBranchName)" create_manifest "beta" "$(Build.SourceBranchName)" + create_manifest "rc" "$(Build.SourceBranchName)" fi displayName: 'Create Meta-Image' From bc5cec97f445ab108e4129a96ae932b5271fbadc Mon Sep 17 00:00:00 2001 From: SukramJ Date: Thu, 22 Aug 2019 18:00:15 +0200 Subject: [PATCH 006/262] Add myself as codeowner to HmIP Cloud (#26140) --- CODEOWNERS | 1 + homeassistant/components/homematicip_cloud/manifest.json | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 81c5aafed30..71520e11acf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -119,6 +119,7 @@ homeassistant/components/hive/* @Rendili @KJonline homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit_controller/* @Jc2k homeassistant/components/homematic/* @pvizeli @danielperna84 +homeassistant/components/homematicip_cloud/* @SukramJ homeassistant/components/honeywell/* @zxdavb homeassistant/components/html5/* @robbiet480 homeassistant/components/http/* @home-assistant/core diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index ee0d2cb1271..2a041ce6689 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,7 @@ "homematicip==0.10.10" ], "dependencies": [], - "codeowners": [] + "codeowners": [ + "@SukramJ" + ] } From 82b1b10c28ac6ee126bc6eac3af2d1344fbdc9c8 Mon Sep 17 00:00:00 2001 From: SukramJ Date: Thu, 22 Aug 2019 18:02:35 +0200 Subject: [PATCH 007/262] Splitt device_state_attributes between device and group for Homematic IP Cloud (#26137) * splitt device_state_attributes between device and group * readd device_state_attributes for access point --- .../components/homematicip_cloud/binary_sensor.py | 8 ++------ homeassistant/components/homematicip_cloud/device.py | 9 +++++---- homeassistant/components/homematicip_cloud/sensor.py | 6 ++++++ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 8ecbfeab01a..97746f3f472 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -38,7 +38,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice -from .device import ATTR_GROUP_MEMBER_UNREACHABLE, ATTR_ID +from .device import ATTR_GROUP_MEMBER_UNREACHABLE, ATTR_MODEL_TYPE _LOGGER = logging.getLogger(__name__) @@ -309,11 +309,7 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, BinarySensorD @property def device_state_attributes(self): """Return the state attributes of the security zone group.""" - attr = super().device_state_attributes - - # Remove ATTR_ID from dict, because security groups don't have - # device id/sgtin, just an ugly uuid that is referenced no where else. - del attr[ATTR_ID] + attr = {ATTR_MODEL_TYPE: self._device.modelType} if self._device.motionDetected: attr[ATTR_MOTIONDETECTED] = True diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 0fffad8e97e..b086eaa29c7 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -117,9 +117,10 @@ class HomematicipGenericDevice(Entity): def device_state_attributes(self): """Return the state attributes of the generic device.""" state_attr = {} - for attr, attr_key in DEVICE_ATTRIBUTES.items(): - attr_value = getattr(self._device, attr, None) - if attr_value: - state_attr[attr_key] = attr_value + if isinstance(self._device, AsyncDevice): + for attr, attr_key in DEVICE_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value return state_attr diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index add03c6b644..c15b3121d3a 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -34,6 +34,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice +from .device import ATTR_MODEL_TYPE _LOGGER = logging.getLogger(__name__) @@ -142,6 +143,11 @@ class HomematicipAccesspointStatus(HomematicipGenericDevice): """Return the unit this state is expressed in.""" return "%" + @property + def device_state_attributes(self): + """Return the state attributes of the security zone group.""" + return {ATTR_MODEL_TYPE: self._device.modelType} + class HomematicipHeatingThermostat(HomematicipGenericDevice): """Representation of a HomematicIP heating thermostat device.""" From 2d432da14c06740763867d7b49c6ef8e79c3026f Mon Sep 17 00:00:00 2001 From: Johann Kellerman Date: Thu, 22 Aug 2019 18:19:27 +0200 Subject: [PATCH 008/262] DuckDNS setup backoff (#25899) --- homeassistant/components/duckdns/__init__.py | 65 ++++++++-- tests/components/duckdns/test_init.py | 119 ++++++++++++++++--- 2 files changed, 153 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 7d677580177..171d17faff9 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -1,13 +1,17 @@ """Integrate with DuckDNS.""" -from datetime import timedelta import logging +from asyncio import iscoroutinefunction +from datetime import timedelta import voluptuous as vol -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN +from homeassistant.core import callback, CALLBACK_TYPE from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.loader import bind_hass +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -42,25 +46,28 @@ async def async_setup(hass, config): token = config[DOMAIN][CONF_ACCESS_TOKEN] session = async_get_clientsession(hass) - result = await _update_duckdns(session, domain, token) - - if not result: - return False - - async def update_domain_interval(now): + async def update_domain_interval(_now): """Update the DuckDNS entry.""" - await _update_duckdns(session, domain, token) + return await _update_duckdns(session, domain, token) + + intervals = ( + INTERVAL, + timedelta(minutes=1), + timedelta(minutes=5), + timedelta(minutes=15), + timedelta(minutes=30), + ) + async_track_time_interval_backoff(hass, update_domain_interval, intervals) async def update_domain_service(call): """Update the DuckDNS entry.""" await _update_duckdns(session, domain, token, txt=call.data[ATTR_TXT]) - async_track_time_interval(hass, update_domain_interval, INTERVAL) hass.services.async_register( DOMAIN, SERVICE_SET_TXT, update_domain_service, schema=SERVICE_TXT_SCHEMA ) - return result + return True _SENTINEL = object() @@ -89,3 +96,37 @@ async def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False) return False return True + + +@callback +@bind_hass +def async_track_time_interval_backoff(hass, action, intervals) -> CALLBACK_TYPE: + """Add a listener that fires repetitively at every timedelta interval.""" + if not iscoroutinefunction: + _LOGGER.error("action needs to be a coroutine and return True/False") + return + + if not isinstance(intervals, (list, tuple)): + intervals = (intervals,) + remove = None + failed = 0 + + async def interval_listener(now): + """Handle elapsed intervals with backoff.""" + nonlocal failed, remove + try: + failed += 1 + if await action(now): + failed = 0 + finally: + delay = intervals[failed] if failed < len(intervals) else intervals[-1] + remove = async_track_point_in_utc_time(hass, interval_listener, now + delay) + + hass.async_run_job(interval_listener, dt_util.utcnow()) + + def remove_listener(): + """Remove interval listener.""" + if remove: + remove() # pylint: disable=not-callable + + return remove_listener diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py index 0fdfebac66e..0213d9aefa6 100644 --- a/tests/components/duckdns/test_init.py +++ b/tests/components/duckdns/test_init.py @@ -1,28 +1,29 @@ """Test the DuckDNS component.""" -import asyncio from datetime import timedelta - +import logging import pytest from homeassistant.loader import bind_hass from homeassistant.setup import async_setup_component from homeassistant.components import duckdns from homeassistant.util.dt import utcnow +from homeassistant.components.duckdns import async_track_time_interval_backoff from tests.common import async_fire_time_changed DOMAIN = "bla" TOKEN = "abcdefgh" +_LOGGER = logging.getLogger(__name__) +INTERVAL = duckdns.INTERVAL @bind_hass -@asyncio.coroutine -def async_set_txt(hass, txt): +async def async_set_txt(hass, txt): """Set the txt record. Pass in None to remove it. This is a legacy helper method. Do not use it for new tests. """ - yield from hass.services.async_call( + await hass.services.async_call( duckdns.DOMAIN, duckdns.SERVICE_SET_TXT, {duckdns.ATTR_TXT: txt}, blocking=True ) @@ -41,40 +42,60 @@ def setup_duckdns(hass, aioclient_mock): ) -@asyncio.coroutine -def test_setup(hass, aioclient_mock): +async def test_setup(hass, aioclient_mock): """Test setup works if update passes.""" aioclient_mock.get( duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="OK" ) - result = yield from async_setup_component( + result = await async_setup_component( hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} ) + + await hass.async_block_till_done() + assert result assert aioclient_mock.call_count == 1 async_fire_time_changed(hass, utcnow() + timedelta(minutes=5)) - yield from hass.async_block_till_done() + await hass.async_block_till_done() assert aioclient_mock.call_count == 2 -@asyncio.coroutine -def test_setup_fails_if_update_fails(hass, aioclient_mock): +async def test_setup_backoff(hass, aioclient_mock): """Test setup fails if first update fails.""" aioclient_mock.get( duckdns.UPDATE_URL, params={"domains": DOMAIN, "token": TOKEN}, text="KO" ) - result = yield from async_setup_component( + result = await async_setup_component( hass, duckdns.DOMAIN, {"duckdns": {"domain": DOMAIN, "access_token": TOKEN}} ) - assert not result + assert result + await hass.async_block_till_done() assert aioclient_mock.call_count == 1 + # Copy of the DuckDNS intervals from duckdns/__init__.py + intervals = ( + INTERVAL, + timedelta(minutes=1), + timedelta(minutes=5), + timedelta(minutes=15), + timedelta(minutes=30), + ) + tme = utcnow() + await hass.async_block_till_done() -@asyncio.coroutine -def test_service_set_txt(hass, aioclient_mock, setup_duckdns): + _LOGGER.debug("Backoff...") + for idx in range(1, len(intervals)): + tme += intervals[idx] + async_fire_time_changed(hass, tme) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == idx + 1 + + +async def test_service_set_txt(hass, aioclient_mock, setup_duckdns): """Test set txt service call.""" # Empty the fixture mock requests aioclient_mock.clear_requests() @@ -86,12 +107,11 @@ def test_service_set_txt(hass, aioclient_mock, setup_duckdns): ) assert aioclient_mock.call_count == 0 - yield from async_set_txt(hass, "some-txt") + await async_set_txt(hass, "some-txt") assert aioclient_mock.call_count == 1 -@asyncio.coroutine -def test_service_clear_txt(hass, aioclient_mock, setup_duckdns): +async def test_service_clear_txt(hass, aioclient_mock, setup_duckdns): """Test clear txt service call.""" # Empty the fixture mock requests aioclient_mock.clear_requests() @@ -103,5 +123,66 @@ def test_service_clear_txt(hass, aioclient_mock, setup_duckdns): ) assert aioclient_mock.call_count == 0 - yield from async_set_txt(hass, None) + await async_set_txt(hass, None) assert aioclient_mock.call_count == 1 + + +async def test_async_track_time_interval_backoff(hass): + """Test setup fails if first update fails.""" + ret_val = False + call_count = 0 + tme = None + + async def _return(now): + nonlocal call_count, ret_val, tme + if tme is None: + tme = now + call_count += 1 + return ret_val + + intervals = ( + INTERVAL, + INTERVAL * 2, + INTERVAL * 5, + INTERVAL * 9, + INTERVAL * 10, + INTERVAL * 11, + INTERVAL * 12, + ) + + async_track_time_interval_backoff(hass, _return, intervals) + await hass.async_block_till_done() + + assert call_count == 1 + + _LOGGER.debug("Backoff...") + for idx in range(1, len(intervals)): + tme += intervals[idx] + async_fire_time_changed(hass, tme) + await hass.async_block_till_done() + + assert call_count == idx + 1 + + _LOGGER.debug("Max backoff reached - intervals[-1]") + for _idx in range(1, 10): + tme += intervals[-1] + async_fire_time_changed(hass, tme) + await hass.async_block_till_done() + + assert call_count == idx + 1 + _idx + + _LOGGER.debug("Reset backoff") + call_count = 0 + ret_val = True + tme += intervals[-1] + async_fire_time_changed(hass, tme) + await hass.async_block_till_done() + assert call_count == 1 + + _LOGGER.debug("No backoff - intervals[0]") + for _idx in range(2, 10): + tme += intervals[0] + async_fire_time_changed(hass, tme) + await hass.async_block_till_done() + + assert call_count == _idx From aff151c90a11d872ca5717300aee3044492b02e5 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Thu, 22 Aug 2019 11:01:56 -0700 Subject: [PATCH 009/262] Load user-provided descriptions for python_scripts (#26069) * Load user-provided descriptions for python_scripts * Import SERVICE_DESCRIPTION_CACHE * Use async_set_service_schema to register service descriptions * Add python_script tests for loading service descriptions * Use async/await in test --- .../components/python_script/__init__.py | 15 +++ tests/components/python_script/test_init.py | 100 +++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 788da6a8d64..715c06aca43 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -9,8 +9,10 @@ import voluptuous as vol from homeassistant.const import SERVICE_RELOAD from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.service import async_set_service_schema from homeassistant.loader import bind_hass from homeassistant.util import sanitize_filename +from homeassistant.util.yaml.loader import load_yaml import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -90,10 +92,23 @@ def discover_scripts(hass): continue hass.services.remove(DOMAIN, existing_service) + # Load user-provided service descriptions from python_scripts/services.yaml + services_yaml = os.path.join(path, "services.yaml") + if os.path.exists(services_yaml): + services_dict = load_yaml(services_yaml) + else: + services_dict = {} + for fil in glob.iglob(os.path.join(path, "*.py")): name = os.path.splitext(os.path.basename(fil))[0] hass.services.register(DOMAIN, name, python_script_service_handler) + service_desc = { + "description": services_dict.get(name, {}).get("description", ""), + "fields": services_dict.get(name, {}).get("fields", {}), + } + async_set_service_schema(hass, DOMAIN, name, service_desc) + @bind_hass def execute_script(hass, name, data=None): diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py index fcf1519d4c7..d7732c00f94 100644 --- a/tests/components/python_script/test_init.py +++ b/tests/components/python_script/test_init.py @@ -3,8 +3,11 @@ import asyncio import logging from unittest.mock import patch, mock_open +from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.setup import async_setup_component -from homeassistant.components.python_script import execute +from homeassistant.components.python_script import DOMAIN, execute, FOLDER + +from tests.common import patch_yaml_files @asyncio.coroutine @@ -289,6 +292,101 @@ def test_reload(hass): assert hass.services.has_service("python_script", "reload") +async def test_service_descriptions(hass): + """Test that service descriptions are loaded and reloaded correctly.""" + # Test 1: no user-provided services.yaml file + scripts1 = [ + "/some/config/dir/python_scripts/hello.py", + "/some/config/dir/python_scripts/world_beer.py", + ] + + service_descriptions1 = ( + "hello:\n" + " description: Description of hello.py.\n" + " fields:\n" + " fake_param:\n" + " description: Parameter used by hello.py.\n" + " example: 'This is a test of python_script.hello'" + ) + services_yaml1 = { + "{}/{}/services.yaml".format( + hass.config.config_dir, FOLDER + ): service_descriptions1 + } + + with patch( + "homeassistant.components.python_script.os.path.isdir", return_value=True + ), patch( + "homeassistant.components.python_script.glob.iglob", return_value=scripts1 + ), patch( + "homeassistant.components.python_script.os.path.exists", return_value=True + ), patch_yaml_files( + services_yaml1 + ): + await async_setup_component(hass, DOMAIN, {}) + + descriptions = await async_get_all_descriptions(hass) + + assert len(descriptions) == 1 + + assert descriptions[DOMAIN]["hello"]["description"] == "Description of hello.py." + assert ( + descriptions[DOMAIN]["hello"]["fields"]["fake_param"]["description"] + == "Parameter used by hello.py." + ) + assert ( + descriptions[DOMAIN]["hello"]["fields"]["fake_param"]["example"] + == "This is a test of python_script.hello" + ) + + assert descriptions[DOMAIN]["world_beer"]["description"] == "" + assert bool(descriptions[DOMAIN]["world_beer"]["fields"]) is False + + # Test 2: user-provided services.yaml file + scripts2 = [ + "/some/config/dir/python_scripts/hello2.py", + "/some/config/dir/python_scripts/world_beer.py", + ] + + service_descriptions2 = ( + "hello2:\n" + " description: Description of hello2.py.\n" + " fields:\n" + " fake_param:\n" + " description: Parameter used by hello2.py.\n" + " example: 'This is a test of python_script.hello2'" + ) + services_yaml2 = { + "{}/{}/services.yaml".format( + hass.config.config_dir, FOLDER + ): service_descriptions2 + } + + with patch( + "homeassistant.components.python_script.os.path.isdir", return_value=True + ), patch( + "homeassistant.components.python_script.glob.iglob", return_value=scripts2 + ), patch( + "homeassistant.components.python_script.os.path.exists", return_value=True + ), patch_yaml_files( + services_yaml2 + ): + await hass.services.async_call(DOMAIN, "reload", {}, blocking=True) + descriptions = await async_get_all_descriptions(hass) + + assert len(descriptions) == 1 + + assert descriptions[DOMAIN]["hello2"]["description"] == "Description of hello2.py." + assert ( + descriptions[DOMAIN]["hello2"]["fields"]["fake_param"]["description"] + == "Parameter used by hello2.py." + ) + assert ( + descriptions[DOMAIN]["hello2"]["fields"]["fake_param"]["example"] + == "This is a test of python_script.hello2" + ) + + @asyncio.coroutine def test_sleep_warns_one(hass, caplog): """Test time.sleep warns once.""" From bc17170f954c5fc12bee2ef121d4da6ee55fd013 Mon Sep 17 00:00:00 2001 From: Paul Annekov Date: Thu, 22 Aug 2019 22:26:08 +0300 Subject: [PATCH 010/262] Fix tuya switch state (#26145) * bump tuyaha 0.0.3 * bump tuyaha 0.0.3 --- homeassistant/components/tuya/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 57eb3f17584..8d47d8a0173 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -3,7 +3,7 @@ "name": "Tuya", "documentation": "https://www.home-assistant.io/components/tuya", "requirements": [ - "tuyaha==0.0.2" + "tuyaha==0.0.3" ], "dependencies": [], "codeowners": [] diff --git a/requirements_all.txt b/requirements_all.txt index 33d6be841cf..3f0e9daa673 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1854,7 +1854,7 @@ tplink==0.2.1 transmissionrpc==0.11 # homeassistant.components.tuya -tuyaha==0.0.2 +tuyaha==0.0.3 # homeassistant.components.twentemilieu twentemilieu==0.1.0 From bff5b00a09dcbe15c07ddcbce705c9487c0afcfb Mon Sep 17 00:00:00 2001 From: Phil Cole Date: Thu, 22 Aug 2019 20:40:48 +0100 Subject: [PATCH 011/262] Nissanleaf login fix (#26139) * Upgrade to pycarwings2.9 per 25 July 2019 API change * Remove rest of location tracker. Fix get_status_from_update call. --- .../components/nissan_leaf/__init__.py | 87 ++++--------------- .../components/nissan_leaf/device_tracker.py | 46 ---------- .../components/nissan_leaf/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 21 insertions(+), 116 deletions(-) delete mode 100644 homeassistant/components/nissan_leaf/device_tracker.py diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index 409b4d38208..38b7018af6c 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -24,14 +24,12 @@ DOMAIN = "nissan_leaf" DATA_LEAF = "nissan_leaf_data" DATA_BATTERY = "battery" -DATA_LOCATION = "location" DATA_CHARGING = "charging" DATA_PLUGGED_IN = "plugged_in" DATA_CLIMATE = "climate" DATA_RANGE_AC = "range_ac_on" DATA_RANGE_AC_OFF = "range_ac_off" -CONF_NCONNECT = "nissan_connect" CONF_INTERVAL = "update_interval" CONF_CHARGING_INTERVAL = "update_interval_charging" CONF_CLIMATE_INTERVAL = "update_interval_climate" @@ -61,7 +59,6 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_REGION): vol.In(CONF_VALID_REGIONS), - vol.Optional(CONF_NCONNECT, default=True): cv.boolean, vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): ( vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)) ), @@ -84,7 +81,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -LEAF_COMPONENTS = ["sensor", "switch", "binary_sensor", "device_tracker"] +LEAF_COMPONENTS = ["sensor", "switch", "binary_sensor"] SIGNAL_UPDATE_LEAF = "nissan_leaf_update" @@ -177,8 +174,7 @@ def setup(hass, config): hass.data[DATA_LEAF][leaf.vin] = data_store for component in LEAF_COMPONENTS: - if component != "device_tracker" or car_config[CONF_NCONNECT]: - load_platform(hass, component, DOMAIN, {}, car_config) + load_platform(hass, component, DOMAIN, {}, car_config) async_track_point_in_utc_time( hass, data_store.async_update_data, utcnow() + INITIAL_UPDATE @@ -209,24 +205,20 @@ class LeafDataStore: self.hass = hass self.leaf = leaf self.car_config = car_config - self.nissan_connect = car_config[CONF_NCONNECT] self.force_miles = car_config[CONF_FORCE_MILES] self.data = {} self.data[DATA_CLIMATE] = False self.data[DATA_BATTERY] = 0 self.data[DATA_CHARGING] = False - self.data[DATA_LOCATION] = False self.data[DATA_RANGE_AC] = 0 self.data[DATA_RANGE_AC_OFF] = 0 self.data[DATA_PLUGGED_IN] = False self.next_update = None self.last_check = None self.request_in_progress = False - # Timestamp of last successful response from battery, - # climate or location. + # Timestamp of last successful response from battery or climate. self.last_battery_response = None self.last_climate_response = None - self.last_location_response = None self._remove_listener = None async def async_update_data(self, now): @@ -334,20 +326,6 @@ class LeafDataStore: except CarwingsError: _LOGGER.error("Error fetching climate info") - if self.nissan_connect: - try: - location_response = await self.async_get_location() - - if location_response is None: - _LOGGER.debug("Empty Location Response Received") - self.data[DATA_LOCATION] = None - else: - _LOGGER.debug("Location Response: %s", location_response.__dict__) - self.data[DATA_LOCATION] = location_response - self.last_location_response = utcnow() - except CarwingsError: - _LOGGER.error("Error fetching location info") - self.request_in_progress = False async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF) @@ -364,19 +342,6 @@ class LeafDataStore: from pycarwings2 import CarwingsError try: - # First, check nissan servers for the latest data - start_server_info = await self.hass.async_add_executor_job( - self.leaf.get_latest_battery_status - ) - - # Store the date from the nissan servers - start_date = self._extract_start_date(start_server_info) - if start_date is None: - _LOGGER.info("No start date from servers. Aborting") - return None - - _LOGGER.debug("Start server date=%s", start_date) - # Request battery update from the car _LOGGER.debug("Requesting battery update, %s", self.leaf.vin) request = await self.hass.async_add_executor_job(self.leaf.request_update) @@ -393,21 +358,30 @@ class LeafDataStore: ) await asyncio.sleep(PYCARWINGS2_SLEEP) - # Note leaf.get_status_from_update is always returning 0, so - # don't try to use it anymore. - server_info = await self.hass.async_add_executor_job( - self.leaf.get_latest_battery_status + # We don't use the response from get_status_from_update + # apart from knowing that the car has responded saying it + # has given the latest battery status to Nissan. + check_result_info = await self.hass.async_add_executor_job( + self.leaf.get_status_from_update, request ) - latest_date = self._extract_start_date(server_info) - _LOGGER.debug("Latest server date=%s", latest_date) - if latest_date is not None and latest_date != start_date: + if check_result_info is not None: + # Get the latest battery status from Nissan servers. + # This has the SOC in it. + server_info = await self.hass.async_add_executor_job( + self.leaf.get_latest_battery_status + ) return server_info _LOGGER.debug( "%s attempts exceeded return latest data from server", MAX_RESPONSE_ATTEMPTS, ) + # Get the latest data from the nissan servers, even though + # it may be out of date, it's better than nothing. + server_info = await self.hass.async_add_executor_job( + self.leaf.get_latest_battery_status + ) return server_info except CarwingsError: _LOGGER.error("An error occurred getting battery status.") @@ -465,29 +439,6 @@ class LeafDataStore: _LOGGER.debug("Climate result not returned by Nissan servers") return False - async def async_get_location(self): - """Get location from Nissan servers.""" - request = await self.hass.async_add_executor_job(self.leaf.request_location) - for attempt in range(MAX_RESPONSE_ATTEMPTS): - if attempt > 0: - _LOGGER.debug( - "Location data not in yet. (%s) (%s). " "Waiting %s seconds", - self.leaf.vin, - attempt, - PYCARWINGS2_SLEEP, - ) - await asyncio.sleep(PYCARWINGS2_SLEEP) - - location_status = await self.hass.async_add_executor_job( - self.leaf.get_status_from_location, request - ) - - if location_status is not None: - _LOGGER.debug("Location_status=%s", location_status.__dict__) - break - - return location_status - class LeafEntity(Entity): """Base class for Nissan Leaf entity.""" diff --git a/homeassistant/components/nissan_leaf/device_tracker.py b/homeassistant/components/nissan_leaf/device_tracker.py deleted file mode 100644 index 11d18ee5a8e..00000000000 --- a/homeassistant/components/nissan_leaf/device_tracker.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Support for tracking a Nissan Leaf.""" -import logging - -from homeassistant.helpers.dispatcher import dispatcher_connect -from homeassistant.util import slugify - -from . import DATA_LEAF, DATA_LOCATION, SIGNAL_UPDATE_LEAF - -_LOGGER = logging.getLogger(__name__) - -ICON_CAR = "mdi:car" - - -def setup_scanner(hass, config, see, discovery_info=None): - """Set up the Nissan Leaf tracker.""" - if discovery_info is None: - return False - - def see_vehicle(): - """Handle the reporting of the vehicle position.""" - for vin, datastore in hass.data[DATA_LEAF].items(): - host_name = datastore.leaf.nickname - dev_id = "nissan_leaf_{}".format(slugify(host_name)) - if not datastore.data[DATA_LOCATION]: - _LOGGER.debug("No position found for vehicle %s", vin) - return - _LOGGER.debug( - "Updating device_tracker for %s with position %s", - datastore.leaf.nickname, - datastore.data[DATA_LOCATION].__dict__, - ) - attrs = {"updated_on": datastore.last_location_response} - see( - dev_id=dev_id, - host_name=host_name, - gps=( - datastore.data[DATA_LOCATION].latitude, - datastore.data[DATA_LOCATION].longitude, - ), - attributes=attrs, - icon=ICON_CAR, - ) - - dispatcher_connect(hass, SIGNAL_UPDATE_LEAF, see_vehicle) - - return True diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json index ab94c01b7c1..70aaa112414 100644 --- a/homeassistant/components/nissan_leaf/manifest.json +++ b/homeassistant/components/nissan_leaf/manifest.json @@ -3,7 +3,7 @@ "name": "Nissan leaf", "documentation": "https://www.home-assistant.io/components/nissan_leaf", "requirements": [ - "pycarwings2==2.8" + "pycarwings2==2.9" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index 3f0e9daa673..61c9a484309 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1071,7 +1071,7 @@ pyblackbird==0.5 pybotvac==0.0.15 # homeassistant.components.nissan_leaf -pycarwings2==2.8 +pycarwings2==2.9 # homeassistant.components.cloudflare pycfdns==0.0.1 From aa56b4dd30b459f9c45b62870e00dc8ee9904478 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Aug 2019 14:12:24 -0700 Subject: [PATCH 012/262] Log warning if disabled entities receive updates. (#26143) * Log warning if disabled entities receive updates. * Fix test * Always set entity ID on disabled entities --- homeassistant/helpers/entity.py | 13 ++++++++ homeassistant/helpers/entity_platform.py | 6 ++-- .../components/config/test_entity_registry.py | 21 ++++++++++--- tests/helpers/test_entity.py | 31 +++++++++++++++++-- tests/helpers/test_entity_platform.py | 2 +- 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index aecdf45dde5..7de41415f08 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -99,6 +99,9 @@ class Entity: # If we reported if this entity was slow _slow_reported = False + # If we reported this entity is updated while disabled + _disabled_reported = False + # Protect for multiple updates _update_staged = False @@ -273,6 +276,16 @@ class Entity: @callback def _async_write_ha_state(self): """Write the state to the state machine.""" + if self.registry_entry and self.registry_entry.disabled_by: + if not self._disabled_reported: + self._disabled_reported = True + _LOGGER.warning( + "Entity %s is incorrectly being triggered for updates while it is disabled. This is a bug in the %s integration.", + self.entity_id, + self.platform.platform_name, + ) + return + start = timer() attr = {} diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 74351ac50af..4a6a3038fd0 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -349,6 +349,9 @@ class EntityPlatform: disabled_by=disabled_by, ) + entity.registry_entry = entry + entity.entity_id = entry.entity_id + if entry.disabled: self.logger.info( "Not adding entity %s because it's disabled", @@ -358,9 +361,6 @@ class EntityPlatform: ) return - entity.registry_entry = entry - entity.entity_id = entry.entity_id - # We won't generate an entity ID if the platform has already set one # We will however make sure that platform cannot pick a registered ID elif entity.entity_id is not None and entity_registry.async_is_registered( diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index f18abe9b0e2..64328a0c8c5 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -127,13 +127,13 @@ async def test_update_entity(hass, client): assert state is not None assert state.name == "before update" + # UPDATE NAME await client.send_json( { "id": 6, "type": "config/entity_registry/update", "entity_id": "test_domain.world", "name": "after update", - "disabled_by": "user", } ) @@ -142,7 +142,7 @@ async def test_update_entity(hass, client): assert msg["result"] == { "config_entry_id": None, "device_id": None, - "disabled_by": "user", + "disabled_by": None, "platform": "test_platform", "entity_id": "test_domain.world", "name": "after update", @@ -151,13 +151,26 @@ async def test_update_entity(hass, client): state = hass.states.get("test_domain.world") assert state.name == "after update" - assert registry.entities["test_domain.world"].disabled_by == "user" - + # UPDATE DISABLED_BY TO USER await client.send_json( { "id": 7, "type": "config/entity_registry/update", "entity_id": "test_domain.world", + "disabled_by": "user", + } + ) + + msg = await client.receive_json() + + assert registry.entities["test_domain.world"].disabled_by == "user" + + # UPDATE DISABLED_BY TO NONE + await client.send_json( + { + "id": 8, + "type": "config/entity_registry/update", + "entity_id": "test_domain.world", "disabled_by": None, } ) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 58f76d396c1..94650592d8e 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -7,13 +7,13 @@ from unittest.mock import MagicMock, patch, PropertyMock import pytest -import homeassistant.helpers.entity as entity +from homeassistant.helpers import entity, entity_registry from homeassistant.core import Context from homeassistant.const import ATTR_HIDDEN, ATTR_DEVICE_CLASS from homeassistant.config import DATA_CUSTOMIZE from homeassistant.helpers.entity_values import EntityValues -from tests.common import get_test_home_assistant +from tests.common import get_test_home_assistant, mock_registry def test_generate_entity_id_requires_hass_or_ids(): @@ -499,3 +499,30 @@ async def test_set_context_expired(hass): assert hass.states.get("hello.world").context != context assert ent._context is None assert ent._context_set is None + + +async def test_warn_disabled(hass, caplog): + """Test we warn once if we write to a disabled entity.""" + entry = entity_registry.RegistryEntry( + entity_id="hello.world", + unique_id="test-unique-id", + platform="test-platform", + disabled_by="user", + ) + mock_registry(hass, {"hello.world": entry}) + + ent = entity.Entity() + ent.hass = hass + ent.entity_id = "hello.world" + ent.registry_entry = entry + ent.platform = MagicMock(platform_name="test-platform") + + caplog.clear() + ent.async_write_ha_state() + assert hass.states.get("hello.world") is None + assert "Entity hello.world is incorrectly being triggered" in caplog.text + + caplog.clear() + ent.async_write_ha_state() + assert hass.states.get("hello.world") is None + assert caplog.text == "" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 606a4c82096..caf8bb702af 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -491,7 +491,7 @@ async def test_registry_respect_entity_disabled(hass): platform = MockEntityPlatform(hass) entity = MockEntity(unique_id="1234") await platform.async_add_entities([entity]) - assert entity.entity_id is None + assert entity.entity_id == "test_domain.world" assert hass.states.async_entity_ids() == [] From a4eeaac24c65ad416eeb9d10ca7a510aa85a139f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Aug 2019 15:05:57 -0700 Subject: [PATCH 013/262] Updated frontend to 20190822.0 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 648fc8b96df..8d6271183bd 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/components/frontend", "requirements": [ - "home-assistant-frontend==20190821.0" + "home-assistant-frontend==20190822.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b26e1c7e59f..0f4fb56970b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ contextvars==2.4;python_version<"3.7" cryptography==2.7 distro==1.4.0 hass-nabucasa==0.17 -home-assistant-frontend==20190821.0 +home-assistant-frontend==20190822.0 importlib-metadata==0.19 jinja2>=2.10.1 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 61c9a484309..d3d60e6a43e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -624,7 +624,7 @@ hole==0.5.0 holidays==0.9.11 # homeassistant.components.frontend -home-assistant-frontend==20190821.0 +home-assistant-frontend==20190822.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d254f72e9b..b5d139719ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -176,7 +176,7 @@ hdate==0.9.0 holidays==0.9.11 # homeassistant.components.frontend -home-assistant-frontend==20190821.0 +home-assistant-frontend==20190822.0 # homeassistant.components.homekit_controller homekit[IP]==0.15.0 From f704a8e90e7986cb9281fcd6d83a0b9b9e0f26d8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 22 Aug 2019 17:32:43 -0700 Subject: [PATCH 014/262] Reload config entry when entity enabled in entity registry, remove entity if disabled. (#26120) * Reload config entry when disabled_by updated in entity registry * Add types * Remove entities that get disabled * Remove unnecessary domain checks. * Attach handler in async_setup * Remove unused var * Type * Fix test * Fix tests --- homeassistant/config_entries.py | 116 ++++++++++++++++-- homeassistant/helpers/entity.py | 4 + homeassistant/helpers/entity_registry.py | 2 +- .../components/config/test_entity_registry.py | 1 + tests/helpers/test_entity.py | 31 +++++ tests/helpers/test_entity_registry.py | 1 + tests/test_config_entries.py | 76 ++++++++++++ 7 files changed, 219 insertions(+), 12 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2e1fbea14d1..c2da37943c1 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3,13 +3,7 @@ import asyncio import logging import functools import uuid -from typing import ( - Any, - Callable, - List, - Optional, - Set, # noqa pylint: disable=unused-import -) +from typing import Any, Callable, List, Optional, Set import weakref import attr @@ -19,6 +13,7 @@ from homeassistant.core import callback, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady from homeassistant.setup import async_setup_component, async_process_deps_reqs from homeassistant.util.decorator import Registry +from homeassistant.helpers import entity_registry # mypy: allow-untyped-defs @@ -161,8 +156,6 @@ class ConfigEntry: try: component = integration.get_component() - if self.domain == integration.domain: - integration.get_platform("config_flow") except ImportError as err: _LOGGER.error( "Error importing integration %s to set up %s config entry: %s", @@ -174,8 +167,20 @@ class ConfigEntry: self.state = ENTRY_STATE_SETUP_ERROR return - # Perform migration - if integration.domain == self.domain: + if self.domain == integration.domain: + try: + integration.get_platform("config_flow") + except ImportError as err: + _LOGGER.error( + "Error importing platform config_flow from integration %s to set up %s config entry: %s", + integration.domain, + self.domain, + err, + ) + self.state = ENTRY_STATE_SETUP_ERROR + return + + # Perform migration if not await self.async_migrate(hass): self.state = ENTRY_STATE_MIGRATION_ERROR return @@ -383,6 +388,7 @@ class ConfigEntries: self._hass_config = hass_config self._entries = [] # type: List[ConfigEntry] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + EntityRegistryDisabledHandler(hass).async_setup() @callback def async_domains(self) -> List[str]: @@ -757,3 +763,91 @@ class SystemOptions: def as_dict(self): """Return dictionary version of this config entrys system options.""" return {"disable_new_entities": self.disable_new_entities} + + +class EntityRegistryDisabledHandler: + """Handler to handle when entities related to config entries updating disabled_by.""" + + RELOAD_AFTER_UPDATE_DELAY = 30 + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the handler.""" + self.hass = hass + self.registry: Optional[entity_registry.EntityRegistry] = None + self.changed: Set[str] = set() + self._remove_call_later: Optional[Callable[[], None]] = None + + @callback + def async_setup(self) -> None: + """Set up the disable handler.""" + self.hass.bus.async_listen( + entity_registry.EVENT_ENTITY_REGISTRY_UPDATED, self._handle_entry_updated + ) + + async def _handle_entry_updated(self, event): + """Handle entity registry entry update.""" + if ( + event.data["action"] != "update" + or "disabled_by" not in event.data["changes"] + ): + return + + if self.registry is None: + self.registry = await entity_registry.async_get_registry(self.hass) + + entity_entry = self.registry.async_get(event.data["entity_id"]) + + if ( + # Stop if no entry found + entity_entry is None + # Stop if entry not connected to config entry + or entity_entry.config_entry_id is None + # Stop if the entry got disabled. In that case the entity handles it + # themselves. + or entity_entry.disabled_by + ): + return + + config_entry = self.hass.config_entries.async_get_entry( + entity_entry.config_entry_id + ) + + if config_entry.entry_id not in self.changed and await support_entry_unload( + self.hass, config_entry.domain + ): + self.changed.add(config_entry.entry_id) + + if not self.changed: + return + + # We are going to delay reloading on *every* entity registry change so that + # if a user is happily clicking along, it will only reload at the end. + + if self._remove_call_later: + self._remove_call_later() + + self._remove_call_later = self.hass.helpers.event.async_call_later( + self.RELOAD_AFTER_UPDATE_DELAY, self._handle_reload + ) + + async def _handle_reload(self, _now): + """Handle a reload.""" + self._remove_call_later = None + to_reload = self.changed + self.changed = set() + + _LOGGER.info( + "Reloading config entries because disabled_by changed in entity registry: %s", + ", ".join(self.changed), + ) + + await asyncio.gather( + *[self.hass.config_entries.async_reload(entry_id) for entry_id in to_reload] + ) + + +async def support_entry_unload(hass: HomeAssistant, domain: str) -> bool: + """Test if a domain supports entry unloading.""" + integration = await loader.async_get_integration(hass, domain) + component = integration.get_component() + return hasattr(component, "async_unload_entry") diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7de41415f08..bd96e1bafdb 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -503,6 +503,10 @@ class Entity: old = self.registry_entry self.registry_entry = ent_reg.async_get(data["entity_id"]) + if self.registry_entry.disabled_by is not None: + await self.async_remove() + return + if self.registry_entry.entity_id == old.entity_id: self.async_write_ha_state() return diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3d84313a5c6..7d81f62fa1c 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -302,7 +302,7 @@ class EntityRegistry: self.async_schedule_save() - data = {"action": "update", "entity_id": entity_id} + data = {"action": "update", "entity_id": entity_id, "changes": list(changes)} if old.entity_id != entity_id: data["old_entity_id"] = old.entity_id diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 64328a0c8c5..9472d888254 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -163,6 +163,7 @@ async def test_update_entity(hass, client): msg = await client.receive_json() + assert hass.states.get("test_domain.world") is None assert registry.entities["test_domain.world"].disabled_by == "user" # UPDATE DISABLED_BY TO NONE diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 94650592d8e..3c89a5c6537 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -526,3 +526,34 @@ async def test_warn_disabled(hass, caplog): ent.async_write_ha_state() assert hass.states.get("hello.world") is None assert caplog.text == "" + + +async def test_disabled_in_entity_registry(hass): + """Test entity is removed if we disable entity registry entry.""" + entry = entity_registry.RegistryEntry( + entity_id="hello.world", + unique_id="test-unique-id", + platform="test-platform", + disabled_by="user", + ) + registry = mock_registry(hass, {"hello.world": entry}) + + ent = entity.Entity() + ent.hass = hass + ent.entity_id = "hello.world" + ent.registry_entry = entry + ent.platform = MagicMock(platform_name="test-platform") + + await ent.async_internal_added_to_hass() + ent.async_write_ha_state() + assert hass.states.get("hello.world") is None + + entry2 = registry.async_update_entity("hello.world", disabled_by=None) + await hass.async_block_till_done() + assert entry2 != entry + assert ent.registry_entry == entry2 + + entry3 = registry.async_update_entity("hello.world", disabled_by="user") + await hass.async_block_till_done() + assert entry3 != entry2 + assert ent.registry_entry == entry3 diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index aee6b6f19a3..9debbdbcba7 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -219,6 +219,7 @@ async def test_updating_config_entry_id(hass, registry, update_events): assert update_events[0]["entity_id"] == entry.entity_id assert update_events[1]["action"] == "update" assert update_events[1]["entity_id"] == entry.entity_id + assert update_events[1]["changes"] == ["config_entry_id"] async def test_removing_config_entry_id(hass, registry, update_events): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index ca6872a7a2c..d9dd614c9a5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -20,6 +20,7 @@ from tests.common import ( MockEntity, mock_integration, mock_entity_platform, + mock_registry, ) @@ -925,3 +926,78 @@ async def test_init_custom_integration(hass): return_value=mock_coro(integration), ): await hass.config_entries.flow.async_init("bla") + + +async def test_support_entry_unload(hass): + """Test unloading entry.""" + assert await config_entries.support_entry_unload(hass, "light") + assert not await config_entries.support_entry_unload(hass, "auth") + + +async def test_reload_entry_entity_registry_ignores_no_entry(hass): + """Test reloading entry in entity registry skips if no config entry linked.""" + handler = config_entries.EntityRegistryDisabledHandler(hass) + registry = mock_registry(hass) + + # Test we ignore entities without config entry + entry = registry.async_get_or_create("light", "hue", "123") + registry.async_update_entity(entry.entity_id, disabled_by="user") + await hass.async_block_till_done() + assert not handler.changed + assert handler._remove_call_later is None + + +async def test_reload_entry_entity_registry_works(hass): + """Test we schedule an entry to be reloaded if disabled_by is updated.""" + handler = config_entries.EntityRegistryDisabledHandler(hass) + handler.async_setup() + registry = mock_registry(hass) + + config_entry = MockConfigEntry( + domain="comp", state=config_entries.ENTRY_STATE_LOADED + ) + config_entry.add_to_hass(hass) + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + mock_unload_entry = MagicMock(return_value=mock_coro(True)) + mock_integration( + hass, + MockModule( + "comp", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + # Only changing disabled_by should update trigger + entity_entry = registry.async_get_or_create( + "light", "hue", "123", config_entry=config_entry + ) + registry.async_update_entity(entity_entry.entity_id, name="yo") + await hass.async_block_till_done() + assert not handler.changed + assert handler._remove_call_later is None + + # Disable entity, we should not do anything, only act when enabled. + registry.async_update_entity(entity_entry.entity_id, disabled_by="user") + await hass.async_block_till_done() + assert not handler.changed + assert handler._remove_call_later is None + + # Enable entity, check we are reloading config entry. + registry.async_update_entity(entity_entry.entity_id, disabled_by=None) + await hass.async_block_till_done() + assert handler.changed == {config_entry.entry_id} + assert handler._remove_call_later is not None + + async_fire_time_changed( + hass, + dt.utcnow() + + timedelta( + seconds=config_entries.EntityRegistryDisabledHandler.RELOAD_AFTER_UPDATE_DELAY + + 1 + ), + ) + await hass.async_block_till_done() + + assert len(mock_unload_entry.mock_calls) == 1 From 432f6569ad9bbd545180afc0283ac0b674f8a755 Mon Sep 17 00:00:00 2001 From: Tyler Page Date: Fri, 23 Aug 2019 01:23:19 -0700 Subject: [PATCH 015/262] Venstar: define success for all branches of set_temperature() (#26148) * define success for all branches * add operation_mode to error when unexpected value * fix black linting * black linting * fix weird black linting result --- homeassistant/components/venstar/climate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py index e75ee2387ee..7e1ae1ecd60 100644 --- a/homeassistant/components/venstar/climate.py +++ b/homeassistant/components/venstar/climate.py @@ -265,9 +265,11 @@ class VenstarThermostat(ClimateDevice): elif operation_mode == self._client.MODE_AUTO: success = self._client.set_setpoints(temp_low, temp_high) else: + success = False _LOGGER.error( "The thermostat is currently not in a mode " - "that supports target temperature" + "that supports target temperature: %s", + operation_mode, ) if not success: From 2b6c5eeb1df2b0bf9eeb65ff2869969077ddb130 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 23 Aug 2019 13:54:44 +0200 Subject: [PATCH 016/262] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 6b986329291..2e537fbb774 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -198,7 +198,7 @@ stages: } sudo docker pull homeassistant/amd64-homeassistant:$(Build.SourceBranchName) - sudo docker pull homeassistant/i368-homeassistant:$(Build.SourceBranchName) + sudo docker pull homeassistant/i386-homeassistant:$(Build.SourceBranchName) sudo docker pull homeassistant/armhf-homeassistant:$(Build.SourceBranchName) sudo docker pull homeassistant/armv7-homeassistant:$(Build.SourceBranchName) sudo docker pull homeassistant/aarch64-homeassistant:$(Build.SourceBranchName) From 17750a604ec6566ca46fd49abbbd6ddd10a60544 Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Fri, 23 Aug 2019 08:13:06 -0400 Subject: [PATCH 017/262] Add NWS weather (#23647) * Add nws weather. * Hassfest * Address multiple comments * Add NWS icon weather code link * Add metar fallback. Use metar code from nws observation if normal api is missing data. * only get 1 observation - we dont use more than 1 * add mocked metar for tests * lint * mock metar package for all tests * add check for metar attributes * catch errors in setup * add timeout error * handle request exceptions * check and test for missing observations * refactor to new pynws * change to simpler api * Make py3.5 compatible Remove f string * bump pynws version * gen_requirements * fix wind bearing observation * Revert "Make py3.5 compatible" This reverts commit 4946d91779a6e539ea43e667b2265557a49a0bb5. * Precommit black missed a file? * black test * add exceptional weather condition * bump pynws version * update requirements_all * address comments * move observation and forecast outside try-except-else * Revert "move observation and forecast outside try-except-else" This reverts commit 53b78b32837b55b8a0b61de6192e846f6a486754. * remove else from update forecast block * remove unneeded ConfigEntryNotReady import * add scan_interval, reduce min_time_between_updates * pytest tests * lint test docstring * use async await * lat and lon inclusive in config --- CODEOWNERS | 1 + homeassistant/components/nws/__init__.py | 1 + homeassistant/components/nws/manifest.json | 8 + homeassistant/components/nws/weather.py | 378 +++++++ homeassistant/components/weather/__init__.py | 6 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/nws/test_weather.py | 274 +++++ tests/fixtures/nws-weather-fore-null.json | 80 ++ tests/fixtures/nws-weather-fore-valid.json | 80 ++ tests/fixtures/nws-weather-obs-null.json | 161 +++ tests/fixtures/nws-weather-obs-valid.json | 161 +++ tests/fixtures/nws-weather-sta-valid.json | 996 +++++++++++++++++++ 14 files changed, 2150 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/nws/__init__.py create mode 100644 homeassistant/components/nws/manifest.json create mode 100644 homeassistant/components/nws/weather.py create mode 100644 tests/components/nws/test_weather.py create mode 100644 tests/fixtures/nws-weather-fore-null.json create mode 100644 tests/fixtures/nws-weather-fore-valid.json create mode 100644 tests/fixtures/nws-weather-obs-null.json create mode 100644 tests/fixtures/nws-weather-obs-valid.json create mode 100644 tests/fixtures/nws-weather-sta-valid.json diff --git a/CODEOWNERS b/CODEOWNERS index 71520e11acf..3ede39518c1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -190,6 +190,7 @@ homeassistant/components/notify/* @home-assistant/core homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nuki/* @pschmitt +homeassistant/components/nws/* @MatthewFlamm homeassistant/components/ohmconnect/* @robbiet480 homeassistant/components/onboarding/* @home-assistant/core homeassistant/components/opentherm_gw/* @mvn23 diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py new file mode 100644 index 00000000000..dde2f6dee11 --- /dev/null +++ b/homeassistant/components/nws/__init__.py @@ -0,0 +1 @@ +"""NWS Integration.""" diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json new file mode 100644 index 00000000000..b0e5fdb2088 --- /dev/null +++ b/homeassistant/components/nws/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "nws", + "name": "National Weather Service", + "documentation": "https://www.home-assistant.io/components/nws", + "dependencies": [], + "codeowners": ["@MatthewFlamm"], + "requirements": ["pynws==0.7.4"] +} diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py new file mode 100644 index 00000000000..23cf84411a3 --- /dev/null +++ b/homeassistant/components/nws/weather.py @@ -0,0 +1,378 @@ +"""Support for NWS weather service.""" +from collections import OrderedDict +from datetime import timedelta +from json import JSONDecodeError +import logging + +import aiohttp +from pynws import SimpleNWS +import voluptuous as vol + +from homeassistant.components.weather import ( + WeatherEntity, + PLATFORM_SCHEMA, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_SPEED, + ATTR_FORECAST_WIND_BEARING, +) +from homeassistant.const import ( + CONF_API_KEY, + CONF_NAME, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_MODE, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PRESSURE_HPA, + PRESSURE_PA, + PRESSURE_INHG, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import config_validation as cv +from homeassistant.util import Throttle +from homeassistant.util.distance import convert as convert_distance +from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.temperature import convert as convert_temperature + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Data from National Weather Service/NOAA" + +SCAN_INTERVAL = timedelta(minutes=15) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +CONF_STATION = "station" + +ATTR_FORECAST_DETAIL_DESCRIPTION = "detailed_description" +ATTR_FORECAST_PRECIP_PROB = "precipitation_probability" +ATTR_FORECAST_DAYTIME = "daytime" + +# Ordered so that a single condition can be chosen from multiple weather codes. +# Catalog of NWS icon weather codes listed at: +# https://api.weather.gov/icons +CONDITION_CLASSES = OrderedDict( + [ + ( + "exceptional", + [ + "Tornado", + "Hurricane conditions", + "Tropical storm conditions", + "Dust", + "Smoke", + "Haze", + "Hot", + "Cold", + ], + ), + ("snowy", ["Snow", "Sleet", "Blizzard"]), + ( + "snowy-rainy", + [ + "Rain/snow", + "Rain/sleet", + "Freezing rain/snow", + "Freezing rain", + "Rain/freezing rain", + ], + ), + ("hail", []), + ( + "lightning-rainy", + [ + "Thunderstorm (high cloud cover)", + "Thunderstorm (medium cloud cover)", + "Thunderstorm (low cloud cover)", + ], + ), + ("lightning", []), + ("pouring", []), + ( + "rainy", + [ + "Rain", + "Rain showers (high cloud cover)", + "Rain showers (low cloud cover)", + ], + ), + ("windy-variant", ["Mostly cloudy and windy", "Overcast and windy"]), + ( + "windy", + [ + "Fair/clear and windy", + "A few clouds and windy", + "Partly cloudy and windy", + ], + ), + ("fog", ["Fog/mist"]), + ("clear", ["Fair/clear"]), # sunny and clear-night + ("cloudy", ["Mostly cloudy", "Overcast"]), + ("partlycloudy", ["A few clouds", "Partly cloudy"]), + ] +) + +ERRORS = (aiohttp.ClientError, JSONDecodeError) + +FORECAST_MODE = ["daynight", "hourly"] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Inclusive( + CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" + ): cv.longitude, + vol.Optional(CONF_MODE, default="daynight"): vol.In(FORECAST_MODE), + vol.Optional(CONF_STATION): cv.string, + vol.Required(CONF_API_KEY): cv.string, + } +) + + +def convert_condition(time, weather): + """ + Convert NWS codes to HA condition. + + Choose first condition in CONDITION_CLASSES that exists in weather code. + If no match is found, return first condition from NWS + """ + conditions = [w[0] for w in weather] + prec_probs = [w[1] or 0 for w in weather] + + # Choose condition with highest priority. + cond = next( + ( + key + for key, value in CONDITION_CLASSES.items() + if any(condition in value for condition in conditions) + ), + conditions[0], + ) + + if cond == "clear": + if time == "day": + return "sunny", max(prec_probs) + if time == "night": + return "clear-night", max(prec_probs) + return cond, max(prec_probs) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the NWS weather platform.""" + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + station = config.get(CONF_STATION) + api_key = config[CONF_API_KEY] + mode = config[CONF_MODE] + + websession = async_get_clientsession(hass) + # ID request as being from HA, pynws prepends the api_key in addition + api_key_ha = f"{api_key} homeassistant" + nws = SimpleNWS(latitude, longitude, api_key_ha, mode, websession) + + _LOGGER.debug("Setting up station: %s", station) + try: + await nws.set_station(station) + except ERRORS as status: + _LOGGER.error( + "Error getting station list for %s: %s", (latitude, longitude), status + ) + raise PlatformNotReady + + _LOGGER.debug("Station list: %s", nws.stations) + _LOGGER.debug( + "Initialized for coordinates %s, %s -> station %s", + latitude, + longitude, + nws.station, + ) + + async_add_entities([NWSWeather(nws, mode, hass.config.units, config)], True) + + +class NWSWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, nws, mode, units, config): + """Initialise the platform with a data instance and station name.""" + self.nws = nws + self.station_name = config.get(CONF_NAME, self.nws.station) + self.is_metric = units.is_metric + self.mode = mode + + self.observation = None + self._forecast = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update Condition.""" + _LOGGER.debug("Updating station observations %s", self.nws.station) + try: + await self.nws.update_observation() + except ERRORS as status: + _LOGGER.error( + "Error updating observation from station %s: %s", + self.nws.station, + status, + ) + else: + self.observation = self.nws.observation + _LOGGER.debug("Updating forecast") + try: + await self.nws.update_forecast() + except ERRORS as status: + _LOGGER.error( + "Error updating forecast from station %s: %s", self.nws.station, status + ) + return + self._forecast = self.nws.forecast + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def name(self): + """Return the name of the station.""" + return self.station_name + + @property + def temperature(self): + """Return the current temperature.""" + temp_c = None + if self.observation: + temp_c = self.observation.get("temperature") + if temp_c: + return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) + return None + + @property + def pressure(self): + """Return the current pressure.""" + pressure_pa = None + if self.observation: + pressure_pa = self.observation.get("seaLevelPressure") + if pressure_pa is None: + return None + if self.is_metric: + pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA) + pressure = round(pressure) + else: + pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_INHG) + pressure = round(pressure, 2) + return pressure + + @property + def humidity(self): + """Return the name of the sensor.""" + humidity = None + if self.observation: + humidity = self.observation.get("relativeHumidity") + return humidity + + @property + def wind_speed(self): + """Return the current windspeed.""" + wind_m_s = None + if self.observation: + wind_m_s = self.observation.get("windSpeed") + if wind_m_s is None: + return None + wind_m_hr = wind_m_s * 3600 + + if self.is_metric: + wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_KILOMETERS) + else: + wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_MILES) + return round(wind) + + @property + def wind_bearing(self): + """Return the current wind bearing (degrees).""" + wind_bearing = None + if self.observation: + wind_bearing = self.observation.get("windDirection") + return wind_bearing + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def condition(self): + """Return current condition.""" + weather = None + if self.observation: + weather = self.observation.get("iconWeather") + time = self.observation.get("iconTime") + + if weather: + cond, _ = convert_condition(time, weather) + return cond + return None + + @property + def visibility(self): + """Return visibility.""" + vis_m = None + if self.observation: + vis_m = self.observation.get("visibility") + if vis_m is None: + return None + + if self.is_metric: + vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_KILOMETERS) + else: + vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_MILES) + return round(vis, 0) + + @property + def forecast(self): + """Return forecast.""" + if self._forecast is None: + return None + forecast = [] + for forecast_entry in self._forecast: + data = { + ATTR_FORECAST_DETAIL_DESCRIPTION: forecast_entry.get( + "detailedForecast" + ), + ATTR_FORECAST_TEMP: forecast_entry.get("temperature"), + ATTR_FORECAST_TIME: forecast_entry.get("startTime"), + } + + if self.mode == "daynight": + data[ATTR_FORECAST_DAYTIME] = forecast_entry.get("isDaytime") + time = forecast_entry.get("iconTime") + weather = forecast_entry.get("iconWeather") + if time and weather: + cond, precip = convert_condition(time, weather) + else: + cond, precip = None, None + data[ATTR_FORECAST_CONDITION] = cond + data[ATTR_FORECAST_PRECIP_PROB] = precip + + data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing") + wind_speed = forecast_entry.get("windSpeedAvg") + if wind_speed: + if self.is_metric: + data[ATTR_FORECAST_WIND_SPEED] = round( + convert_distance(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) + ) + else: + data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed) + else: + data[ATTR_FORECAST_WIND_SPEED] = None + forecast.append(data) + return forecast diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 8f276279ee5..fd122f66ac2 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -125,11 +125,11 @@ class WeatherEntity(Entity): @property def state_attributes(self): """Return the state attributes.""" - data = { - ATTR_WEATHER_TEMPERATURE: show_temp( + data = {} + if self.temperature is not None: + data[ATTR_WEATHER_TEMPERATURE] = show_temp( self.hass, self.temperature, self.temperature_unit, self.precision ) - } humidity = self.humidity if humidity is not None: diff --git a/requirements_all.txt b/requirements_all.txt index d3d60e6a43e..c8e79616e17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1308,6 +1308,9 @@ pynuki==1.3.3 # homeassistant.components.nut pynut2==2.1.2 +# homeassistant.components.nws +pynws==0.7.4 + # homeassistant.components.nx584 pynx584==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5d139719ef..c90ad27554e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -294,6 +294,9 @@ pymfy==0.5.2 # homeassistant.components.monoprice pymonoprice==0.3 +# homeassistant.components.nws +pynws==0.7.4 + # homeassistant.components.nx584 pynx584==0.4 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 6643fcf7aa9..dd36771994b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -120,6 +120,7 @@ TEST_REQUIREMENTS = ( "pylitejet", "pymfy", "pymonoprice", + "pynws", "pynx584", "pyopenuv", "pyotp", diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py new file mode 100644 index 00000000000..436d25750fc --- /dev/null +++ b/tests/components/nws/test_weather.py @@ -0,0 +1,274 @@ +"""Tests for the NWS weather component.""" +from homeassistant.components.nws.weather import ATTR_FORECAST_PRECIP_PROB +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED, +) +from homeassistant.components.weather import ( + ATTR_FORECAST, + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, +) + +from homeassistant.const import ( + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PRESSURE_INHG, + PRESSURE_PA, + PRESSURE_HPA, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.util.pressure import convert as convert_pressure +from homeassistant.util.distance import convert as convert_distance +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.setup import async_setup_component + +from tests.common import load_fixture, assert_setup_component + +EXP_OBS_IMP = { + ATTR_WEATHER_TEMPERATURE: round( + convert_temperature(26.7, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ), + ATTR_WEATHER_WIND_BEARING: 190, + ATTR_WEATHER_WIND_SPEED: round( + convert_distance(2.6, LENGTH_METERS, LENGTH_MILES) * 3600 + ), + ATTR_WEATHER_PRESSURE: round( + convert_pressure(101040, PRESSURE_PA, PRESSURE_INHG), 2 + ), + ATTR_WEATHER_VISIBILITY: round( + convert_distance(16090, LENGTH_METERS, LENGTH_MILES) + ), + ATTR_WEATHER_HUMIDITY: 64, +} + +EXP_OBS_METR = { + ATTR_WEATHER_TEMPERATURE: round(26.7), + ATTR_WEATHER_WIND_BEARING: 190, + ATTR_WEATHER_WIND_SPEED: round( + convert_distance(2.6, LENGTH_METERS, LENGTH_KILOMETERS) * 3600 + ), + ATTR_WEATHER_PRESSURE: round(convert_pressure(101040, PRESSURE_PA, PRESSURE_HPA)), + ATTR_WEATHER_VISIBILITY: round( + convert_distance(16090, LENGTH_METERS, LENGTH_KILOMETERS) + ), + ATTR_WEATHER_HUMIDITY: 64, +} + +EXP_FORE_IMP = { + ATTR_FORECAST_CONDITION: "lightning-rainy", + ATTR_FORECAST_TIME: "2019-08-12T20:00:00-04:00", + ATTR_FORECAST_TEMP: 70, + ATTR_FORECAST_WIND_SPEED: 10, + ATTR_FORECAST_WIND_BEARING: 180, + ATTR_FORECAST_PRECIP_PROB: 90, +} + +EXP_FORE_METR = { + ATTR_FORECAST_CONDITION: "lightning-rainy", + ATTR_FORECAST_TIME: "2019-08-12T20:00:00-04:00", + ATTR_FORECAST_TEMP: round(convert_temperature(70, TEMP_FAHRENHEIT, TEMP_CELSIUS)), + ATTR_FORECAST_WIND_SPEED: round( + convert_distance(10, LENGTH_MILES, LENGTH_KILOMETERS) + ), + ATTR_FORECAST_WIND_BEARING: 180, + ATTR_FORECAST_PRECIP_PROB: 90, +} + + +MINIMAL_CONFIG = { + "weather": { + "platform": "nws", + "api_key": "x@example.com", + "latitude": 40.0, + "longitude": -85.0, + } +} + +INVALID_CONFIG = { + "weather": {"platform": "nws", "api_key": "x@example.com", "latitude": 40.0} +} + +STAURL = "https://api.weather.gov/points/{},{}/stations" +OBSURL = "https://api.weather.gov/stations/{}/observations/" +FORCURL = "https://api.weather.gov/points/{},{}/forecast" + + +async def test_imperial(hass, aioclient_mock): + """Test with imperial units.""" + aioclient_mock.get( + STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") + ) + aioclient_mock.get( + OBSURL.format("KMIE"), + text=load_fixture("nws-weather-obs-valid.json"), + params={"limit": 1}, + ) + aioclient_mock.get( + FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-valid.json") + ) + + hass.config.units = IMPERIAL_SYSTEM + + with assert_setup_component(1, "weather"): + await async_setup_component(hass, "weather", MINIMAL_CONFIG) + + state = hass.states.get("weather.kmie") + assert state + assert state.state == "sunny" + + data = state.attributes + for key, value in EXP_OBS_IMP.items(): + assert data.get(key) == value + assert state.attributes.get("friendly_name") == "KMIE" + forecast = data.get(ATTR_FORECAST) + for key, value in EXP_FORE_IMP.items(): + assert forecast[0].get(key) == value + + +async def test_metric(hass, aioclient_mock): + """Test with metric units.""" + aioclient_mock.get( + STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") + ) + aioclient_mock.get( + OBSURL.format("KMIE"), + text=load_fixture("nws-weather-obs-valid.json"), + params={"limit": 1}, + ) + aioclient_mock.get( + FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-valid.json") + ) + + hass.config.units = METRIC_SYSTEM + + with assert_setup_component(1, "weather"): + await async_setup_component(hass, "weather", MINIMAL_CONFIG) + + state = hass.states.get("weather.kmie") + assert state + assert state.state == "sunny" + + data = state.attributes + for key, value in EXP_OBS_METR.items(): + assert data.get(key) == value + assert state.attributes.get("friendly_name") == "KMIE" + forecast = data.get(ATTR_FORECAST) + for key, value in EXP_FORE_METR.items(): + assert forecast[0].get(key) == value + + +async def test_none(hass, aioclient_mock): + """Test with imperial units.""" + aioclient_mock.get( + STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") + ) + aioclient_mock.get( + OBSURL.format("KMIE"), + text=load_fixture("nws-weather-obs-null.json"), + params={"limit": 1}, + ) + aioclient_mock.get( + FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-null.json") + ) + + hass.config.units = IMPERIAL_SYSTEM + + with assert_setup_component(1, "weather"): + await async_setup_component(hass, "weather", MINIMAL_CONFIG) + + state = hass.states.get("weather.kmie") + assert state + assert state.state == "unknown" + + data = state.attributes + for key in EXP_OBS_IMP: + assert data.get(key) is None + assert state.attributes.get("friendly_name") == "KMIE" + forecast = data.get(ATTR_FORECAST) + for key in EXP_FORE_IMP: + assert forecast[0].get(key) is None + + +async def test_fail_obs(hass, aioclient_mock): + """Test failing observation/forecast update.""" + aioclient_mock.get( + STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") + ) + aioclient_mock.get( + OBSURL.format("KMIE"), + text=load_fixture("nws-weather-obs-valid.json"), + params={"limit": 1}, + status=400, + ) + aioclient_mock.get( + FORCURL.format(40.0, -85.0), + text=load_fixture("nws-weather-fore-valid.json"), + status=400, + ) + + hass.config.units = IMPERIAL_SYSTEM + + with assert_setup_component(1, "weather"): + await async_setup_component(hass, "weather", MINIMAL_CONFIG) + + state = hass.states.get("weather.kmie") + assert state + + +async def test_fail_stn(hass, aioclient_mock): + """Test failing station update.""" + aioclient_mock.get( + STAURL.format(40.0, -85.0), + text=load_fixture("nws-weather-sta-valid.json"), + status=400, + ) + aioclient_mock.get( + OBSURL.format("KMIE"), + text=load_fixture("nws-weather-obs-valid.json"), + params={"limit": 1}, + ) + aioclient_mock.get( + FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-valid.json") + ) + + hass.config.units = IMPERIAL_SYSTEM + + with assert_setup_component(1, "weather"): + await async_setup_component(hass, "weather", MINIMAL_CONFIG) + + state = hass.states.get("weather.kmie") + assert state is None + + +async def test_invalid_config(hass, aioclient_mock): + """Test invalid config..""" + aioclient_mock.get( + STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") + ) + aioclient_mock.get( + OBSURL.format("KMIE"), + text=load_fixture("nws-weather-obs-valid.json"), + params={"limit": 1}, + ) + aioclient_mock.get( + FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-valid.json") + ) + + hass.config.units = IMPERIAL_SYSTEM + + with assert_setup_component(0, "weather"): + await async_setup_component(hass, "weather", INVALID_CONFIG) + + state = hass.states.get("weather.kmie") + assert state is None diff --git a/tests/fixtures/nws-weather-fore-null.json b/tests/fixtures/nws-weather-fore-null.json new file mode 100644 index 00000000000..6085bcdada9 --- /dev/null +++ b/tests/fixtures/nws-weather-fore-null.json @@ -0,0 +1,80 @@ +{ + "@context": [ + "https://raw.githubusercontent.com/geojson/geojson-ld/master/contexts/geojson-base.jsonld", + { + "wx": "https://api.weather.gov/ontology#", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#" + } + ], + "type": "Feature", + "geometry": { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Point", + "coordinates": [ + -85.014692800000006, + 39.993574700000003 + ] + }, + { + "type": "Polygon", + "coordinates": [ + [ + [ + -85.027968599999994, + 40.005368300000001 + ], + [ + -85.0300814, + 39.983399599999998 + ], + [ + -85.001420100000004, + 39.981779299999999 + ], + [ + -84.999301200000005, + 40.0037479 + ], + [ + -85.027968599999994, + 40.005368300000001 + ] + ] + ] + } + ] + }, + "properties": { + "updated": "2019-08-12T23:17:40+00:00", + "units": "us", + "forecastGenerator": "BaselineForecastGenerator", + "generatedAt": "2019-08-13T00:33:19+00:00", + "updateTime": "2019-08-12T23:17:40+00:00", + "validTimes": "2019-08-12T17:00:00+00:00/P8DT6H", + "elevation": { + "value": 366.06479999999999, + "unitCode": "unit:m" + }, + "periods": [ + { + "number": null, + "name": null, + "startTime": null, + "endTime": null, + "isDaytime": null, + "temperature": null, + "temperatureUnit": null, + "temperatureTrend": null, + "windSpeed": null, + "windDirection": null, + "icon": null, + "shortForecast": null, + "detailedForecast": null + } + ] + } +} diff --git a/tests/fixtures/nws-weather-fore-valid.json b/tests/fixtures/nws-weather-fore-valid.json new file mode 100644 index 00000000000..b3f4f4ccea8 --- /dev/null +++ b/tests/fixtures/nws-weather-fore-valid.json @@ -0,0 +1,80 @@ +{ + "@context": [ + "https://raw.githubusercontent.com/geojson/geojson-ld/master/contexts/geojson-base.jsonld", + { + "wx": "https://api.weather.gov/ontology#", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#" + } + ], + "type": "Feature", + "geometry": { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Point", + "coordinates": [ + -85.014692800000006, + 39.993574700000003 + ] + }, + { + "type": "Polygon", + "coordinates": [ + [ + [ + -85.027968599999994, + 40.005368300000001 + ], + [ + -85.0300814, + 39.983399599999998 + ], + [ + -85.001420100000004, + 39.981779299999999 + ], + [ + -84.999301200000005, + 40.0037479 + ], + [ + -85.027968599999994, + 40.005368300000001 + ] + ] + ] + } + ] + }, + "properties": { + "updated": "2019-08-12T23:17:40+00:00", + "units": "us", + "forecastGenerator": "BaselineForecastGenerator", + "generatedAt": "2019-08-13T00:33:19+00:00", + "updateTime": "2019-08-12T23:17:40+00:00", + "validTimes": "2019-08-12T17:00:00+00:00/P8DT6H", + "elevation": { + "value": 366.06479999999999, + "unitCode": "unit:m" + }, + "periods": [ + { + "number": 1, + "name": "Tonight", + "startTime": "2019-08-12T20:00:00-04:00", + "endTime": "2019-08-13T06:00:00-04:00", + "isDaytime": false, + "temperature": 70, + "temperatureUnit": "F", + "temperatureTrend": null, + "windSpeed": "7 to 13 mph", + "windDirection": "S", + "icon": "https://api.weather.gov/icons/land/night/tsra,40/tsra,90?size=medium", + "shortForecast": "Showers And Thunderstorms", + "detailedForecast": "A detailed forecast." + } + ] + } +} diff --git a/tests/fixtures/nws-weather-obs-null.json b/tests/fixtures/nws-weather-obs-null.json new file mode 100644 index 00000000000..36ae66283e5 --- /dev/null +++ b/tests/fixtures/nws-weather-obs-null.json @@ -0,0 +1,161 @@ +{ + "@context": [ + "https://raw.githubusercontent.com/geojson/geojson-ld/master/contexts/geojson-base.jsonld", + { + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + } + } + ], + "type": "FeatureCollection", + "features": [ + { + "id": "https://api.weather.gov/stations/KMIE/observations/2019-08-12T23:53:00+00:00", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.400000000000006, + 40.25 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMIE/observations/2019-08-12T23:53:00+00:00", + "@type": "wx:ObservationStation", + "elevation": { + "value": 286, + "unitCode": "unit:m" + }, + "station": "https://api.weather.gov/stations/KMIE", + "timestamp": "2019-08-12T23:53:00+00:00", + "rawMessage": null, + "textDescription": "Clear", + "icon": null, + "presentWeather": [], + "temperature": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": "qc:V" + }, + "dewpoint": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": "qc:V" + }, + "windDirection": { + "value": null, + "unitCode": "unit:degree_(angle)", + "qualityControl": "qc:V" + }, + "windSpeed": { + "value": null, + "unitCode": "unit:m_s-1", + "qualityControl": "qc:V" + }, + "windGust": { + "value": null, + "unitCode": "unit:m_s-1", + "qualityControl": "qc:Z" + }, + "barometricPressure": { + "value": null, + "unitCode": "unit:Pa", + "qualityControl": "qc:V" + }, + "seaLevelPressure": { + "value": null, + "unitCode": "unit:Pa", + "qualityControl": "qc:V" + }, + "visibility": { + "value": null, + "unitCode": "unit:m", + "qualityControl": "qc:C" + }, + "maxTemperatureLast24Hours": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": null + }, + "minTemperatureLast24Hours": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": null + }, + "precipitationLastHour": { + "value": null, + "unitCode": "unit:m", + "qualityControl": "qc:Z" + }, + "precipitationLast3Hours": { + "value": null, + "unitCode": "unit:m", + "qualityControl": "qc:Z" + }, + "precipitationLast6Hours": { + "value": 0, + "unitCode": "unit:m", + "qualityControl": "qc:C" + }, + "relativeHumidity": { + "value": null, + "unitCode": "unit:percent", + "qualityControl": "qc:C" + }, + "windChill": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": "qc:V" + }, + "heatIndex": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": "qc:V" + }, + "cloudLayers": [ + { + "base": { + "value": null, + "unitCode": "unit:m" + }, + "amount": "CLR" + } + ] + } + } + ] +} diff --git a/tests/fixtures/nws-weather-obs-valid.json b/tests/fixtures/nws-weather-obs-valid.json new file mode 100644 index 00000000000..a6d307fc9b1 --- /dev/null +++ b/tests/fixtures/nws-weather-obs-valid.json @@ -0,0 +1,161 @@ +{ + "@context": [ + "https://raw.githubusercontent.com/geojson/geojson-ld/master/contexts/geojson-base.jsonld", + { + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + } + } + ], + "type": "FeatureCollection", + "features": [ + { + "id": "https://api.weather.gov/stations/KMIE/observations/2019-08-12T23:53:00+00:00", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.400000000000006, + 40.25 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMIE/observations/2019-08-12T23:53:00+00:00", + "@type": "wx:ObservationStation", + "elevation": { + "value": 286, + "unitCode": "unit:m" + }, + "station": "https://api.weather.gov/stations/KMIE", + "timestamp": "2019-08-12T23:53:00+00:00", + "rawMessage": "KMIE 122353Z 19005KT 10SM CLR 27/19 A2987 RMK AO2 SLP104 60000 T02670194 10272 20250 58002", + "textDescription": "Clear", + "icon": "https://api.weather.gov/icons/land/day/skc?size=medium", + "presentWeather": [], + "temperature": { + "value": 26.700000000000045, + "unitCode": "unit:degC", + "qualityControl": "qc:V" + }, + "dewpoint": { + "value": 19.400000000000034, + "unitCode": "unit:degC", + "qualityControl": "qc:V" + }, + "windDirection": { + "value": 190, + "unitCode": "unit:degree_(angle)", + "qualityControl": "qc:V" + }, + "windSpeed": { + "value": 2.6000000000000001, + "unitCode": "unit:m_s-1", + "qualityControl": "qc:V" + }, + "windGust": { + "value": null, + "unitCode": "unit:m_s-1", + "qualityControl": "qc:Z" + }, + "barometricPressure": { + "value": 101150, + "unitCode": "unit:Pa", + "qualityControl": "qc:V" + }, + "seaLevelPressure": { + "value": 101040, + "unitCode": "unit:Pa", + "qualityControl": "qc:V" + }, + "visibility": { + "value": 16090, + "unitCode": "unit:m", + "qualityControl": "qc:C" + }, + "maxTemperatureLast24Hours": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": null + }, + "minTemperatureLast24Hours": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": null + }, + "precipitationLastHour": { + "value": null, + "unitCode": "unit:m", + "qualityControl": "qc:Z" + }, + "precipitationLast3Hours": { + "value": null, + "unitCode": "unit:m", + "qualityControl": "qc:Z" + }, + "precipitationLast6Hours": { + "value": 0, + "unitCode": "unit:m", + "qualityControl": "qc:C" + }, + "relativeHumidity": { + "value": 64.292485914891955, + "unitCode": "unit:percent", + "qualityControl": "qc:C" + }, + "windChill": { + "value": null, + "unitCode": "unit:degC", + "qualityControl": "qc:V" + }, + "heatIndex": { + "value": 27.981288713580284, + "unitCode": "unit:degC", + "qualityControl": "qc:V" + }, + "cloudLayers": [ + { + "base": { + "value": null, + "unitCode": "unit:m" + }, + "amount": "CLR" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/nws-weather-sta-valid.json b/tests/fixtures/nws-weather-sta-valid.json new file mode 100644 index 00000000000..b4fe086366c --- /dev/null +++ b/tests/fixtures/nws-weather-sta-valid.json @@ -0,0 +1,996 @@ +{ + "@context": [ + "https://raw.githubusercontent.com/geojson/geojson-ld/master/contexts/geojson-base.jsonld", + { + "wx": "https://api.weather.gov/ontology#", + "s": "https://schema.org/", + "geo": "http://www.opengis.net/ont/geosparql#", + "unit": "http://codes.wmo.int/common/unit/", + "@vocab": "https://api.weather.gov/ontology#", + "geometry": { + "@id": "s:GeoCoordinates", + "@type": "geo:wktLiteral" + }, + "city": "s:addressLocality", + "state": "s:addressRegion", + "distance": { + "@id": "s:Distance", + "@type": "s:QuantitativeValue" + }, + "bearing": { + "@type": "s:QuantitativeValue" + }, + "value": { + "@id": "s:value" + }, + "unitCode": { + "@id": "s:unitCode", + "@type": "@id" + }, + "forecastOffice": { + "@type": "@id" + }, + "forecastGridData": { + "@type": "@id" + }, + "publicZone": { + "@type": "@id" + }, + "county": { + "@type": "@id" + }, + "observationStations": { + "@container": "@list", + "@type": "@id" + } + } + ], + "type": "FeatureCollection", + "features": [ + { + "id": "https://api.weather.gov/stations/KMIE", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.393609999999995, + 40.234169999999999 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMIE", + "@type": "wx:ObservationStation", + "elevation": { + "value": 284.988, + "unitCode": "unit:m" + }, + "stationIdentifier": "KMIE", + "name": "Muncie, Delaware County-Johnson Field", + "timeZone": "America/Indiana/Indianapolis" + } + }, + { + "id": "https://api.weather.gov/stations/KVES", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.531899899999999, + 40.2044 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KVES", + "@type": "wx:ObservationStation", + "elevation": { + "value": 306.93360000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KVES", + "name": "Versailles Darke County Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KAID", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.609769999999997, + 40.106119999999997 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KAID", + "@type": "wx:ObservationStation", + "elevation": { + "value": 276.14879999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KAID", + "name": "Anderson Municipal Airport", + "timeZone": "America/Indiana/Indianapolis" + } + }, + { + "id": "https://api.weather.gov/stations/KDAY", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.218609999999998, + 39.906109999999998 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDAY", + "@type": "wx:ObservationStation", + "elevation": { + "value": 306.93360000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KDAY", + "name": "Dayton, Cox Dayton International Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KGEZ", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.799819999999997, + 39.585459999999998 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KGEZ", + "@type": "wx:ObservationStation", + "elevation": { + "value": 244.1448, + "unitCode": "unit:m" + }, + "stationIdentifier": "KGEZ", + "name": "Shelbyville Municipal Airport", + "timeZone": "America/Indiana/Indianapolis" + } + }, + { + "id": "https://api.weather.gov/stations/KMGY", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.224720000000005, + 39.588889999999999 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMGY", + "@type": "wx:ObservationStation", + "elevation": { + "value": 291.9984, + "unitCode": "unit:m" + }, + "stationIdentifier": "KMGY", + "name": "Dayton, Dayton-Wright Brothers Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KHAO", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.520610000000005, + 39.36121 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHAO", + "@type": "wx:ObservationStation", + "elevation": { + "value": 185.0136, + "unitCode": "unit:m" + }, + "stationIdentifier": "KHAO", + "name": "Butler County Regional Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KFFO", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.049999999999997, + 39.833329900000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFFO", + "@type": "wx:ObservationStation", + "elevation": { + "value": 250.85040000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KFFO", + "name": "Dayton / Wright-Patterson Air Force Base", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KCVG", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.672290000000004, + 39.044559999999997 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCVG", + "@type": "wx:ObservationStation", + "elevation": { + "value": 262.12799999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KCVG", + "name": "Cincinnati/Northern Kentucky International Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KEDJ", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.819199999999995, + 40.372300000000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KEDJ", + "@type": "wx:ObservationStation", + "elevation": { + "value": 341.98560000000003, + "unitCode": "unit:m" + }, + "stationIdentifier": "KEDJ", + "name": "Bellefontaine Regional Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KFWA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.206370000000007, + 40.97251 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFWA", + "@type": "wx:ObservationStation", + "elevation": { + "value": 242.9256, + "unitCode": "unit:m" + }, + "stationIdentifier": "KFWA", + "name": "Fort Wayne International Airport", + "timeZone": "America/Indiana/Indianapolis" + } + }, + { + "id": "https://api.weather.gov/stations/KBAK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.900000000000006, + 39.266669999999998 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KBAK", + "@type": "wx:ObservationStation", + "elevation": { + "value": 199.94880000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KBAK", + "name": "Columbus / Bakalar", + "timeZone": "America/Indiana/Indianapolis" + } + }, + { + "id": "https://api.weather.gov/stations/KEYE", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -86.295829999999995, + 39.825000000000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KEYE", + "@type": "wx:ObservationStation", + "elevation": { + "value": 249.93600000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KEYE", + "name": "Indianapolis, Eagle Creek Airpark", + "timeZone": "America/Indiana/Indianapolis" + } + }, + { + "id": "https://api.weather.gov/stations/KLUK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.41583, + 39.105829999999997 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLUK", + "@type": "wx:ObservationStation", + "elevation": { + "value": 146.9136, + "unitCode": "unit:m" + }, + "stationIdentifier": "KLUK", + "name": "Cincinnati, Cincinnati Municipal Airport Lunken Field", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KIND", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -86.281599999999997, + 39.725180000000002 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KIND", + "@type": "wx:ObservationStation", + "elevation": { + "value": 240.792, + "unitCode": "unit:m" + }, + "stationIdentifier": "KIND", + "name": "Indianapolis International Airport", + "timeZone": "America/Indiana/Indianapolis" + } + }, + { + "id": "https://api.weather.gov/stations/KAOH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.021389999999997, + 40.708060000000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KAOH", + "@type": "wx:ObservationStation", + "elevation": { + "value": 296.87520000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KAOH", + "name": "Lima, Lima Allen County Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KI69", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.2102, + 39.078400000000002 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KI69", + "@type": "wx:ObservationStation", + "elevation": { + "value": 256.94640000000004, + "unitCode": "unit:m" + }, + "stationIdentifier": "KI69", + "name": "Batavia Clermont County Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KILN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.779169899999999, + 39.428330000000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KILN", + "@type": "wx:ObservationStation", + "elevation": { + "value": 327.96480000000003, + "unitCode": "unit:m" + }, + "stationIdentifier": "KILN", + "name": "Wilmington, Airborne Airpark Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KMRT", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.351600000000005, + 40.224699999999999 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMRT", + "@type": "wx:ObservationStation", + "elevation": { + "value": 311.20080000000002, + "unitCode": "unit:m" + }, + "stationIdentifier": "KMRT", + "name": "Marysville Union County Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KTZR", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.137219999999999, + 39.900829999999999 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KTZR", + "@type": "wx:ObservationStation", + "elevation": { + "value": 276.14879999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KTZR", + "name": "Columbus, Bolton Field Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KFDY", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.668610000000001, + 41.01361 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFDY", + "@type": "wx:ObservationStation", + "elevation": { + "value": 248.10720000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KFDY", + "name": "Findlay, Findlay Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KDLZ", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.114800000000002, + 40.279699999999998 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KDLZ", + "@type": "wx:ObservationStation", + "elevation": { + "value": 288.036, + "unitCode": "unit:m" + }, + "stationIdentifier": "KDLZ", + "name": "Delaware Municipal Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KOSU", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.0780599, + 40.078060000000001 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KOSU", + "@type": "wx:ObservationStation", + "elevation": { + "value": 274.92959999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KOSU", + "name": "Columbus, Ohio State University Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KLCK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -82.933329999999998, + 39.816670000000002 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLCK", + "@type": "wx:ObservationStation", + "elevation": { + "value": 227.07600000000002, + "unitCode": "unit:m" + }, + "stationIdentifier": "KLCK", + "name": "Rickenbacker Air National Guard Base", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KMNN", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.068330000000003, + 40.616669999999999 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMNN", + "@type": "wx:ObservationStation", + "elevation": { + "value": 302.97120000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KMNN", + "name": "Marion, Marion Municipal Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KCMH", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -82.876390000000001, + 39.994999999999997 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCMH", + "@type": "wx:ObservationStation", + "elevation": { + "value": 248.10720000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KCMH", + "name": "Columbus - John Glenn Columbus International Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KFGX", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -83.743399999999994, + 38.541800000000002 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFGX", + "@type": "wx:ObservationStation", + "elevation": { + "value": 277.9776, + "unitCode": "unit:m" + }, + "stationIdentifier": "KFGX", + "name": "Flemingsburg Fleming-Mason Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KFFT", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.903329999999997, + 38.184719999999999 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KFFT", + "@type": "wx:ObservationStation", + "elevation": { + "value": 245.0592, + "unitCode": "unit:m" + }, + "stationIdentifier": "KFFT", + "name": "Frankfort, Capital City Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KLHQ", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -82.663330000000002, + 39.757219900000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLHQ", + "@type": "wx:ObservationStation", + "elevation": { + "value": 263.95679999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KLHQ", + "name": "Lancaster, Fairfield County Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KLOU", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.663610000000006, + 38.227780000000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLOU", + "@type": "wx:ObservationStation", + "elevation": { + "value": 166.11600000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KLOU", + "name": "Louisville, Bowman Field Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KSDF", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -85.72972, + 38.177219999999998 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KSDF", + "@type": "wx:ObservationStation", + "elevation": { + "value": 150.876, + "unitCode": "unit:m" + }, + "stationIdentifier": "KSDF", + "name": "Louisville, Standiford Field", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KVTA", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -82.462500000000006, + 40.022779999999997 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KVTA", + "@type": "wx:ObservationStation", + "elevation": { + "value": 269.13839999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KVTA", + "name": "Newark, Newark Heath Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KLEX", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -84.6114599, + 38.033900000000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KLEX", + "@type": "wx:ObservationStation", + "elevation": { + "value": 291.084, + "unitCode": "unit:m" + }, + "stationIdentifier": "KLEX", + "name": "Lexington Blue Grass Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KMFD", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -82.517780000000002, + 40.820279900000003 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KMFD", + "@type": "wx:ObservationStation", + "elevation": { + "value": 395.02080000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KMFD", + "name": "Mansfield - Mansfield Lahm Regional Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KZZV", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -81.892219999999995, + 39.94444 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KZZV", + "@type": "wx:ObservationStation", + "elevation": { + "value": 274.01519999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KZZV", + "name": "Zanesville, Zanesville Municipal Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KHTS", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -82.555000000000007, + 38.365000000000002 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KHTS", + "@type": "wx:ObservationStation", + "elevation": { + "value": 252.06960000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KHTS", + "name": "Huntington, Tri-State Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KBJJ", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -81.886669999999995, + 40.873060000000002 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KBJJ", + "@type": "wx:ObservationStation", + "elevation": { + "value": 345.94800000000004, + "unitCode": "unit:m" + }, + "stationIdentifier": "KBJJ", + "name": "Wooster, Wayne County Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KPHD", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -81.423609999999996, + 40.471939900000002 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KPHD", + "@type": "wx:ObservationStation", + "elevation": { + "value": 271.88159999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KPHD", + "name": "New Philadelphia, Harry Clever Field", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KPKB", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -81.439170000000004, + 39.344999999999999 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KPKB", + "@type": "wx:ObservationStation", + "elevation": { + "value": 262.12799999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KPKB", + "name": "Parkersburg, Mid-Ohio Valley Regional Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KCAK", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -81.443430000000006, + 40.918109999999999 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCAK", + "@type": "wx:ObservationStation", + "elevation": { + "value": 369.11279999999999, + "unitCode": "unit:m" + }, + "stationIdentifier": "KCAK", + "name": "Akron Canton Regional Airport", + "timeZone": "America/New_York" + } + }, + { + "id": "https://api.weather.gov/stations/KCRW", + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [ + -81.591390000000004, + 38.379440000000002 + ] + }, + "properties": { + "@id": "https://api.weather.gov/stations/KCRW", + "@type": "wx:ObservationStation", + "elevation": { + "value": 299.00880000000001, + "unitCode": "unit:m" + }, + "stationIdentifier": "KCRW", + "name": "Charleston, Yeager Airport", + "timeZone": "America/New_York" + } + } + ], + "observationStations": [ + "https://api.weather.gov/stations/KMIE", + "https://api.weather.gov/stations/KVES", + "https://api.weather.gov/stations/KAID", + "https://api.weather.gov/stations/KDAY", + "https://api.weather.gov/stations/KGEZ", + "https://api.weather.gov/stations/KMGY", + "https://api.weather.gov/stations/KHAO", + "https://api.weather.gov/stations/KFFO", + "https://api.weather.gov/stations/KCVG", + "https://api.weather.gov/stations/KEDJ", + "https://api.weather.gov/stations/KFWA", + "https://api.weather.gov/stations/KBAK", + "https://api.weather.gov/stations/KEYE", + "https://api.weather.gov/stations/KLUK", + "https://api.weather.gov/stations/KIND", + "https://api.weather.gov/stations/KAOH", + "https://api.weather.gov/stations/KI69", + "https://api.weather.gov/stations/KILN", + "https://api.weather.gov/stations/KMRT", + "https://api.weather.gov/stations/KTZR", + "https://api.weather.gov/stations/KFDY", + "https://api.weather.gov/stations/KDLZ", + "https://api.weather.gov/stations/KOSU", + "https://api.weather.gov/stations/KLCK", + "https://api.weather.gov/stations/KMNN", + "https://api.weather.gov/stations/KCMH", + "https://api.weather.gov/stations/KFGX", + "https://api.weather.gov/stations/KFFT", + "https://api.weather.gov/stations/KLHQ", + "https://api.weather.gov/stations/KLOU", + "https://api.weather.gov/stations/KSDF", + "https://api.weather.gov/stations/KVTA", + "https://api.weather.gov/stations/KLEX", + "https://api.weather.gov/stations/KMFD", + "https://api.weather.gov/stations/KZZV", + "https://api.weather.gov/stations/KHTS", + "https://api.weather.gov/stations/KBJJ", + "https://api.weather.gov/stations/KPHD", + "https://api.weather.gov/stations/KPKB", + "https://api.weather.gov/stations/KCAK", + "https://api.weather.gov/stations/KCRW" + ] +} \ No newline at end of file From 55031e6ea497b3219d90dc3a602b97172651c977 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Fri, 23 Aug 2019 06:58:24 -0700 Subject: [PATCH 018/262] Bump androidtv to 0.0.24 (#26158) * Bump androidtv to 0.0.24 * Add unique ID for Fire TV (not just Android TV) --- homeassistant/components/androidtv/manifest.json | 2 +- .../components/androidtv/media_player.py | 15 ++++++++------- requirements_all.txt | 2 +- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 24eb61d52b0..047eaaaf5db 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -3,7 +3,7 @@ "name": "Androidtv", "documentation": "https://www.home-assistant.io/components/androidtv", "requirements": [ - "androidtv==0.0.23" + "androidtv==0.0.24" ], "dependencies": [], "codeowners": ["@JeffLIrion"] diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index ef9293381fd..db4ff9e851e 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -270,6 +270,9 @@ class ADBDevice(MediaPlayerDevice): self._apps.update(apps) self._keys = KEYS + self._device_properties = self.aftv.device_properties + self._unique_id = self._device_properties.get("serialno") + self.turn_on_command = turn_on_command self.turn_off_command = turn_off_command @@ -338,6 +341,11 @@ class ADBDevice(MediaPlayerDevice): """Return the state of the player.""" return self._state + @property + def unique_id(self): + """Return the device unique id.""" + return self._unique_id + @adb_decorator() def media_play(self): """Send play command.""" @@ -412,9 +420,7 @@ class AndroidTVDevice(ADBDevice): super().__init__(aftv, name, apps, turn_on_command, turn_off_command) self._device = None - self._device_properties = self.aftv.device_properties self._is_volume_muted = None - self._unique_id = self._device_properties.get("serialno") self._volume_level = None @adb_decorator(override_available=True) @@ -454,11 +460,6 @@ class AndroidTVDevice(ADBDevice): """Flag media player features that are supported.""" return SUPPORT_ANDROIDTV - @property - def unique_id(self): - """Return the device unique id.""" - return self._unique_id - @property def volume_level(self): """Return the volume level.""" diff --git a/requirements_all.txt b/requirements_all.txt index c8e79616e17..f10b74b9594 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -194,7 +194,7 @@ ambiclimate==0.2.0 amcrest==1.5.3 # homeassistant.components.androidtv -androidtv==0.0.23 +androidtv==0.0.24 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 From 1efa29d6ff60409435fbdfb906ee737c2123de6d Mon Sep 17 00:00:00 2001 From: On Freund Date: Fri, 23 Aug 2019 16:59:25 +0300 Subject: [PATCH 019/262] CoolMaster: Change auto to heat_cool (#26144) --- homeassistant/components/coolmaster/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py index 7379d66777b..8a319c655f6 100644 --- a/homeassistant/components/coolmaster/climate.py +++ b/homeassistant/components/coolmaster/climate.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA from homeassistant.components.climate.const import ( HVAC_MODE_OFF, - HVAC_MODE_AUTO, + HVAC_MODE_HEAT_COOL, HVAC_MODE_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, @@ -33,14 +33,14 @@ AVAILABLE_MODES = [ HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_DRY, - HVAC_MODE_AUTO, + HVAC_MODE_HEAT_COOL, HVAC_MODE_FAN_ONLY, ] CM_TO_HA_STATE = { "heat": HVAC_MODE_HEAT, "cool": HVAC_MODE_COOL, - "auto": HVAC_MODE_AUTO, + "auto": HVAC_MODE_HEAT_COOL, "dry": HVAC_MODE_DRY, "fan": HVAC_MODE_FAN_ONLY, } From decf13b94819a8e7a9135393e87f3435128bd66e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 23 Aug 2019 18:53:33 +0200 Subject: [PATCH 020/262] Use literal string interpolation in core (f-strings) (#26166) --- homeassistant/__main__.py | 6 ++--- homeassistant/auth/__init__.py | 10 +++---- homeassistant/auth/auth_store.py | 2 +- homeassistant/auth/mfa_modules/__init__.py | 6 ++--- homeassistant/auth/providers/__init__.py | 10 +++---- homeassistant/bootstrap.py | 2 +- homeassistant/components/api/__init__.py | 6 ++--- .../components/automation/__init__.py | 6 ++--- homeassistant/components/config/__init__.py | 12 ++++----- .../components/configurator/__init__.py | 4 +-- homeassistant/components/demo/camera.py | 2 +- homeassistant/components/demo/media_player.py | 2 +- homeassistant/components/demo/vacuum.py | 2 +- homeassistant/components/frontend/__init__.py | 8 ++---- homeassistant/components/hassio/__init__.py | 2 +- homeassistant/components/hassio/handler.py | 6 ++--- homeassistant/components/hassio/http.py | 2 +- homeassistant/components/hassio/ingress.py | 10 +++---- homeassistant/components/http/__init__.py | 14 ++++------ homeassistant/components/http/ban.py | 2 +- .../components/http/data_validator.py | 4 +-- homeassistant/components/http/static.py | 2 +- .../components/input_number/__init__.py | 6 ++--- .../components/input_text/__init__.py | 4 +-- .../components/integration/sensor.py | 2 +- .../components/intent_script/__init__.py | 2 +- .../components/python_script/__init__.py | 8 +++--- .../components/recorder/migration.py | 8 +++--- homeassistant/components/script/__init__.py | 2 +- .../components/system_log/__init__.py | 2 +- homeassistant/components/tts/__init__.py | 26 ++++++++----------- .../components/utility_meter/__init__.py | 2 +- .../components/utility_meter/sensor.py | 2 +- homeassistant/components/zeroconf/__init__.py | 2 +- homeassistant/config.py | 10 +++---- homeassistant/core.py | 2 +- homeassistant/data_entry_flow.py | 2 +- homeassistant/exceptions.py | 6 ++--- homeassistant/helpers/check_config.py | 14 +++++----- homeassistant/helpers/entity.py | 8 +++--- homeassistant/helpers/entity_component.py | 2 +- homeassistant/helpers/entity_platform.py | 8 +++--- homeassistant/helpers/entity_registry.py | 4 +-- homeassistant/helpers/intent.py | 14 ++++------ homeassistant/helpers/temperature.py | 2 +- homeassistant/helpers/template.py | 12 ++++----- homeassistant/helpers/translation.py | 6 ++--- homeassistant/loader.py | 23 +++++++--------- homeassistant/requirements.py | 4 +-- homeassistant/scripts/benchmark/__init__.py | 2 +- homeassistant/scripts/credstash.py | 12 ++++----- homeassistant/scripts/keyring.py | 18 ++++++------- homeassistant/setup.py | 4 +-- homeassistant/util/__init__.py | 2 +- homeassistant/util/distance.py | 2 +- homeassistant/util/dt.py | 4 +-- homeassistant/util/pressure.py | 2 +- homeassistant/util/volume.py | 2 +- homeassistant/util/yaml/loader.py | 4 +-- script/gen_requirements_all.py | 22 ++++++---------- script/hassfest/__main__.py | 2 +- script/hassfest/dependencies.py | 2 +- script/hassfest/model.py | 8 +++--- script/lazytox.py | 4 +-- script/translations_download_split.py | 15 +++-------- script/translations_upload_merge.py | 2 +- script/version_bump.py | 16 ++++-------- 67 files changed, 180 insertions(+), 246 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 8ec2a8c2d3c..8765ee6c822 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -168,7 +168,7 @@ def get_arguments() -> argparse.Namespace: parser.add_argument( "--runner", action="store_true", - help="On restart exit with code {}".format(RESTART_EXIT_CODE), + help=f"On restart exit with code {RESTART_EXIT_CODE}", ) parser.add_argument( "--script", nargs=argparse.REMAINDER, help="Run one of the embedded scripts" @@ -240,7 +240,7 @@ def write_pid(pid_file: str) -> None: with open(pid_file, "w") as file: file.write(str(pid)) except IOError: - print("Fatal Error: Unable to write pid file {}".format(pid_file)) + print(f"Fatal Error: Unable to write pid file {pid_file}") sys.exit(1) @@ -326,7 +326,7 @@ def try_to_restart() -> None: thread.is_alive() and not thread.daemon for thread in threading.enumerate() ) if nthreads > 1: - sys.stderr.write("Found {} non-daemonic threads.\n".format(nthreads)) + sys.stderr.write(f"Found {nthreads} non-daemonic threads.\n") # Somehow we sometimes seem to trigger an assertion in the python threading # module. It seems we find threads that have no associated OS level thread diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 2641f0b8f7e..e2778e9f45b 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -278,9 +278,7 @@ class AuthManager: module = self.get_auth_mfa_module(mfa_module_id) if module is None: - raise ValueError( - "Unable find multi-factor auth module: {}".format(mfa_module_id) - ) + raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_setup_user(user.id, data) @@ -295,9 +293,7 @@ class AuthManager: module = self.get_auth_mfa_module(mfa_module_id) if module is None: - raise ValueError( - "Unable find multi-factor auth module: {}".format(mfa_module_id) - ) + raise ValueError(f"Unable find multi-factor auth module: {mfa_module_id}") await module.async_depose_user(user.id) @@ -356,7 +352,7 @@ class AuthManager: ): # Each client_name can only have one # long_lived_access_token type of refresh token - raise ValueError("{} already exists".format(client_name)) + raise ValueError(f"{client_name} already exists") return await self._store.async_create_refresh_token( user, diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 82db0bcf7a9..894819fb3c7 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -94,7 +94,7 @@ class AuthStore: for group_id in group_ids or []: group = self._groups.get(group_id) if group is None: - raise ValueError("Invalid group specified {}".format(group_id)) + raise ValueError(f"Invalid group specified {group_id}") groups.append(group) kwargs = { diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index 5481b8fe08b..baccedeabbf 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -144,15 +144,13 @@ async def auth_mfa_module_from_config( async def _load_mfa_module(hass: HomeAssistant, module_name: str) -> types.ModuleType: """Load an mfa auth module.""" - module_path = "homeassistant.auth.mfa_modules.{}".format(module_name) + module_path = f"homeassistant.auth.mfa_modules.{module_name}" try: module = importlib.import_module(module_path) except ImportError as err: _LOGGER.error("Unable to load mfa module %s: %s", module_name, err) - raise HomeAssistantError( - "Unable to load mfa module {}: {}".format(module_name, err) - ) + raise HomeAssistantError(f"Unable to load mfa module {module_name}: {err}") if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index c35af2e0b96..ee9ef8f94cd 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -144,14 +144,10 @@ async def load_auth_provider_module( ) -> types.ModuleType: """Load an auth provider.""" try: - module = importlib.import_module( - "homeassistant.auth.providers.{}".format(provider) - ) + module = importlib.import_module(f"homeassistant.auth.providers.{provider}") except ImportError as err: _LOGGER.error("Unable to load auth provider %s: %s", provider, err) - raise HomeAssistantError( - "Unable to load auth provider {}: {}".format(provider, err) - ) + raise HomeAssistantError(f"Unable to load auth provider {provider}: {err}") if hass.config.skip_pip or not hasattr(module, "REQUIREMENTS"): return module @@ -166,7 +162,7 @@ async def load_auth_provider_module( # https://github.com/python/mypy/issues/1424 reqs = module.REQUIREMENTS # type: ignore await requirements.async_process_requirements( - hass, "auth provider {}".format(provider), reqs + hass, f"auth provider {provider}", reqs ) processed.add(provider) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index b0eab0da0f3..3e71a588af0 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -163,7 +163,7 @@ def async_enable_logging( # ensure that the handlers it sets up wraps the correct streams. logging.basicConfig(level=logging.INFO) - colorfmt = "%(log_color)s{}%(reset)s".format(fmt) + colorfmt = f"%(log_color)s{fmt}%(reset)s" logging.getLogger().handlers[0].setFormatter( ColoredFormatter( colorfmt, diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ee991535104..d4faa55ed8c 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -138,7 +138,7 @@ class APIEventStream(HomeAssistantView): if payload is stop_obj: break - msg = "data: {}\n\n".format(payload) + msg = f"data: {payload}\n\n" _LOGGER.debug("STREAM %s WRITING %s", id(stop_obj), msg.strip()) await response.write(msg.encode("UTF-8")) except asyncio.TimeoutError: @@ -316,7 +316,7 @@ class APIEventView(HomeAssistantView): event_type, event_data, ha.EventOrigin.remote, self.context(request) ) - return self.json_message("Event {} fired.".format(event_type)) + return self.json_message(f"Event {event_type} fired.") class APIServicesView(HomeAssistantView): @@ -388,7 +388,7 @@ class APITemplateView(HomeAssistantView): return tpl.async_render(data.get("variables")) except (ValueError, TemplateError) as ex: return self.json_message( - "Error rendering template: {}".format(ex), HTTP_BAD_REQUEST + f"Error rendering template: {ex}", HTTP_BAD_REQUEST ) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 5de9336d1d9..1cffd361b19 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -143,7 +143,7 @@ async def async_setup(hass, config): async def turn_onoff_service_handler(service_call): """Handle automation turn on/off service calls.""" tasks = [] - method = "async_{}".format(service_call.service) + method = f"async_{service_call.service}" for entity in await component.async_extract_from_service(service_call): tasks.append(getattr(entity, method)()) @@ -378,7 +378,7 @@ async def _async_process_config(hass, config, component): for list_no, config_block in enumerate(conf): automation_id = config_block.get(CONF_ID) - name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key, list_no) + name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}" hidden = config_block[CONF_HIDE_ENTITY] initial_state = config_block.get(CONF_INITIAL_STATE) @@ -431,7 +431,7 @@ def _async_get_action(hass, config, name): await script_obj.async_run(variables, context) except Exception as err: # pylint: disable=broad-except script_obj.async_log_exception( - _LOGGER, "Error while executing automation {}".format(entity_id), err + _LOGGER, f"Error while executing automation {entity_id}", err ) return action diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 5de11a032c5..6d4b465fceb 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -36,7 +36,7 @@ async def async_setup(hass, config): async def setup_panel(panel_name): """Set up a panel.""" - panel = importlib.import_module(".{}".format(panel_name), __name__) + panel = importlib.import_module(f".{panel_name}", __name__) if not panel: return @@ -44,7 +44,7 @@ async def async_setup(hass, config): success = await panel.async_setup(hass) if success: - key = "{}.{}".format(DOMAIN, panel_name) + key = f"{DOMAIN}.{panel_name}" hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key}) @callback @@ -82,8 +82,8 @@ class BaseEditConfigView(HomeAssistantView): post_write_hook=None, ): """Initialize a config view.""" - self.url = "/api/config/%s/%s/{config_key}" % (component, config_type) - self.name = "api:config:%s:%s" % (component, config_type) + self.url = f"/api/config/{component}/{config_type}/{{config_key}}" + self.name = f"api:config:{component}:{config_type}" self.path = path self.key_schema = key_schema self.data_schema = data_schema @@ -126,14 +126,14 @@ class BaseEditConfigView(HomeAssistantView): try: self.key_schema(config_key) except vol.Invalid as err: - return self.json_message("Key malformed: {}".format(err), 400) + return self.json_message(f"Key malformed: {err}", 400) try: # We just validate, we don't store that data because # we don't want to store the defaults. self.data_schema(data) except vol.Invalid as err: - return self.json_message("Message malformed: {}".format(err), 400) + return self.json_message(f"Message malformed: {err}", 400) hass = request.app["hass"] path = hass.config.path(self.path) diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index 99995959c23..f3b2a41e917 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -61,10 +61,10 @@ def async_request_config( Will return an ID to be used for sequent calls. """ if link_name is not None and link_url is not None: - description += "\n\n[{}]({})".format(link_name, link_url) + description += f"\n\n[{link_name}]({link_url})" if description_image is not None: - description += "\n\n![Description image]({})".format(description_image) + description += f"\n\n![Description image]({description_image})" instance = hass.data.get(_KEY_INSTANCE) diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py index 7ac5fc17c69..0cd77b6112e 100644 --- a/homeassistant/components/demo/camera.py +++ b/homeassistant/components/demo/camera.py @@ -28,7 +28,7 @@ class DemoCamera(Camera): self._images_index = (self._images_index + 1) % 4 image_path = os.path.join( - os.path.dirname(__file__), "demo_{}.jpg".format(self._images_index) + os.path.dirname(__file__), f"demo_{self._images_index}.jpg" ) _LOGGER.debug("Loading camera_image: %s", image_path) with open(image_path, "rb") as file: diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index e3f69be3020..fb64f8015c0 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -417,7 +417,7 @@ class DemoTVShowPlayer(AbstractDemoPlayer): @property def media_title(self): """Return the title of current playing media.""" - return "Chapter {}".format(self._cur_episode) + return f"Chapter {self._cur_episode}" @property def media_series_title(self): diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index ffd3e768b11..2ba704d3925 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -244,7 +244,7 @@ class DemoVacuum(VacuumDevice): if self.supported_features & SUPPORT_SEND_COMMAND == 0: return - self._status = "Executing {}({})".format(command, params) + self._status = f"Executing {command}({params})" self._state = True self.schedule_update_ha_state() diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index d8790b746be..7298ce8c1d0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -274,9 +274,7 @@ async def async_setup(hass, config): ("frontend_latest", True), ("frontend_es5", True), ): - hass.http.register_static_path( - "/{}".format(path), str(root_path / path), should_cache - ) + hass.http.register_static_path(f"/{path}", str(root_path / path), should_cache) hass.http.register_static_path( "/auth/authorize", str(root_path / "authorize.html"), False @@ -294,9 +292,7 @@ async def async_setup(hass, config): # To smooth transition to new urls, add redirects to new urls of dev tools # Added June 27, 2019. Can be removed in 2021. for panel in ("event", "info", "service", "state", "template", "mqtt"): - hass.http.register_redirect( - "/dev-{}".format(panel), "/developer-tools/{}".format(panel) - ) + hass.http.register_redirect(f"/dev-{panel}", f"/developer-tools/{panel}") async_register_built_in_panel( hass, diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 801c20b5c2b..6603728e037 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -271,7 +271,7 @@ async def async_setup(hass, config): hass.components.persistent_notification.async_create( "Config error. See dev-info panel for details.", "Config validating", - "{0}.check_config".format(HASS_DOMAIN), + f"{HASS_DOMAIN}.check_config", ) return diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 10f21556fb3..5213443614c 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -80,7 +80,7 @@ class HassIO: This method return a coroutine. """ - return self.send_command("/addons/{}/info".format(addon), method="get") + return self.send_command(f"/addons/{addon}/info", method="get") @_api_data def get_ingress_panels(self): @@ -120,7 +120,7 @@ class HassIO: This method return a coroutine. """ - return self.send_command("/discovery/{}".format(uuid), method="get") + return self.send_command(f"/discovery/{uuid}", method="get") @_api_bool async def update_hass_api(self, http_config, refresh_token): @@ -156,7 +156,7 @@ class HassIO: with async_timeout.timeout(timeout): request = await self.websession.request( method, - "http://{}{}".format(self._ip, command), + f"http://{self._ip}{command}", json=payload, headers={X_HASSIO: os.environ.get("HASSIO_TOKEN", "")}, ) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index f42aaca4438..3b1b8374510 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -75,7 +75,7 @@ class HassIOView(HomeAssistantView): method = getattr(self._websession, request.method.lower()) client = await method( - "http://{}/{}".format(self._host, path), + f"http://{self._host}/{path}", data=data, headers=headers, timeout=read_timeout, diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 84e2b096362..4ecb9a8419f 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -42,7 +42,7 @@ class HassIOIngress(HomeAssistantView): def _create_url(self, token: str, path: str) -> str: """Create URL to service.""" - return "http://{}/ingress/{}/{}".format(self._host, token, path) + return f"http://{self._host}/ingress/{token}/{path}" async def _handle( self, request: web.Request, token: str, path: str @@ -91,7 +91,7 @@ class HassIOIngress(HomeAssistantView): # Support GET query if request.query_string: - url = "{}?{}".format(url, request.query_string) + url = f"{url}?{request.query_string}" # Start proxy async with self._websession.ws_connect( @@ -175,15 +175,15 @@ def _init_header( headers[X_HASSIO] = os.environ.get("HASSIO_TOKEN", "") # Ingress information - headers[X_INGRESS_PATH] = "/api/hassio_ingress/{}".format(token) + headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" # Set X-Forwarded-For forward_for = request.headers.get(hdrs.X_FORWARDED_FOR) connected_ip = ip_address(request.transport.get_extra_info("peername")[0]) if forward_for: - forward_for = "{}, {!s}".format(forward_for, connected_ip) + forward_for = f"{forward_for}, {connected_ip!s}" else: - forward_for = "{!s}".format(connected_ip) + forward_for = f"{connected_ip!s}" headers[hdrs.X_FORWARDED_FOR] = forward_for # Set X-Forwarded-Host diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 5e474dafa07..a8aaa3390a7 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -133,12 +133,12 @@ class ApiConfig: if host.startswith(("http://", "https://")): self.base_url = host elif use_ssl: - self.base_url = "https://{}".format(host) + self.base_url = f"https://{host}" else: - self.base_url = "http://{}".format(host) + self.base_url = f"http://{host}" if port is not None: - self.base_url += ":{}".format(port) + self.base_url += f":{port}" async def async_setup(hass, config): @@ -268,15 +268,11 @@ class HomeAssistantHTTP: if not hasattr(view, "url"): class_name = view.__class__.__name__ - raise AttributeError( - '{0} missing required attribute "url"'.format(class_name) - ) + raise AttributeError(f'{class_name} missing required attribute "url"') if not hasattr(view, "name"): class_name = view.__class__.__name__ - raise AttributeError( - '{0} missing required attribute "name"'.format(class_name) - ) + raise AttributeError(f'{class_name} missing required attribute "name"') view.register(self.app, self.app.router) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 71e7ff38924..d8fa8853c7f 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -127,7 +127,7 @@ async def process_wrong_login(request): _LOGGER.warning("Banned IP %s for too many login attempts", remote_addr) hass.components.persistent_notification.async_create( - "Too many login attempts from {}".format(remote_addr), + f"Too many login attempts from {remote_addr}", "Banning IP address", NOTIFICATION_ID_BAN, ) diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py index 634a96aa312..5945a4ca402 100644 --- a/homeassistant/components/http/data_validator.py +++ b/homeassistant/components/http/data_validator.py @@ -43,9 +43,7 @@ class RequestDataValidator: kwargs["data"] = self._schema(data) except vol.Invalid as err: _LOGGER.error("Data does not match schema: %s", err) - return view.json_message( - "Message format incorrect: {}".format(err), 400 - ) + return view.json_message(f"Message format incorrect: {err}", 400) result = await method(view, request, *args, **kwargs) return result diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 76844407f7d..952ca473fdc 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -10,7 +10,7 @@ from aiohttp.web_urldispatcher import StaticResource # mypy: allow-untyped-defs CACHE_TIME = 31 * 86400 # = 1 month -CACHE_HEADERS = {hdrs.CACHE_CONTROL: "public, max-age={}".format(CACHE_TIME)} +CACHE_HEADERS = {hdrs.CACHE_CONTROL: f"public, max-age={CACHE_TIME}"} # https://github.com/PyCQA/astroid/issues/633 diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 2564b8b31b4..007ed6517ef 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -49,13 +49,11 @@ def _cv_input_number(cfg): maximum = cfg.get(CONF_MAX) if minimum >= maximum: raise vol.Invalid( - "Maximum ({}) is not greater than minimum ({})".format(minimum, maximum) + f"Maximum ({minimum}) is not greater than minimum ({maximum})" ) state = cfg.get(CONF_INITIAL) if state is not None and (state < minimum or state > maximum): - raise vol.Invalid( - "Initial value {} not in range {}-{}".format(state, minimum, maximum) - ) + raise vol.Invalid(f"Initial value {state} not in range {minimum}-{maximum}") return cfg diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 2b7c7312f71..fc49bd65ced 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -45,12 +45,12 @@ def _cv_input_text(cfg): maximum = cfg.get(CONF_MAX) if minimum > maximum: raise vol.Invalid( - "Max len ({}) is not greater than min len ({})".format(minimum, maximum) + f"Max len ({minimum}) is not greater than min len ({maximum})" ) state = cfg.get(CONF_INITIAL) if state is not None and (len(state) < minimum or len(state) > maximum): raise vol.Invalid( - "Initial value {} length not in range {}-{}".format(state, minimum, maximum) + f"Initial value {state} length not in range {minimum}-{maximum}" ) return cfg diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index d24b70c4be0..236a996794a 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -94,7 +94,7 @@ class IntegrationSensor(RestoreEntity): self._state = 0 self._method = integration_method - self._name = name if name is not None else "{} integral".format(source_entity) + self._name = name if name is not None else f"{source_entity} integral" if unit_of_measurement is None: self._unit_template = "{}{}{}".format( diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index 443a4cbc854..75a0c0e8f97 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -55,7 +55,7 @@ async def async_setup(hass, config): for intent_type, conf in intents.items(): if CONF_ACTION in conf: conf[CONF_ACTION] = script.Script( - hass, conf[CONF_ACTION], "Intent Script {}".format(intent_type) + hass, conf[CONF_ACTION], f"Intent Script {intent_type}" ) intent.async_register(hass, ScriptIntentHandler(intent_type, conf)) diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py index 715c06aca43..af0865bc685 100644 --- a/homeassistant/components/python_script/__init__.py +++ b/homeassistant/components/python_script/__init__.py @@ -113,7 +113,7 @@ def discover_scripts(hass): @bind_hass def execute_script(hass, name, data=None): """Execute a script.""" - filename = "{}.py".format(name) + filename = f"{name}.py" with open(hass.config.path(FOLDER, sanitize_filename(filename))) as fil: source = fil.read() execute(hass, filename, source, data) @@ -166,9 +166,7 @@ def execute(hass, filename, source, data=None): or isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME ): - raise ScriptError( - "Not allowed to access {}.{}".format(obj.__class__.__name__, name) - ) + raise ScriptError(f"Not allowed to access {obj.__class__.__name__}.{name}") return getattr(obj, name, default) @@ -188,7 +186,7 @@ def execute(hass, filename, source, data=None): "_iter_unpack_sequence_": guarded_iter_unpack_sequence, "_unpack_sequence_": guarded_unpack_sequence, } - logger = logging.getLogger("{}.{}".format(__name__, filename)) + logger = logging.getLogger(f"{__name__}.{filename}") local = {"hass": hass, "data": data or {}, "logger": logger} try: diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index aee993fa104..3de0430d8f3 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -107,7 +107,7 @@ def _drop_index(engine, table_name, index_name): # Engines like DB2/Oracle try: - engine.execute(text("DROP INDEX {index}".format(index=index_name))) + engine.execute(text(f"DROP INDEX {index_name}")) except SQLAlchemyError: pass else: @@ -170,7 +170,7 @@ def _add_columns(engine, table_name, columns_def): table_name, ) - columns_def = ["ADD {}".format(col_def) for col_def in columns_def] + columns_def = [f"ADD {col_def}" for col_def in columns_def] try: engine.execute( @@ -265,9 +265,7 @@ def _apply_update(engine, new_version, old_version): # 'context_parent_id CHARACTER(36)', # ]) else: - raise ValueError( - "No schema migration defined for version {}".format(new_version) - ) + raise ValueError(f"No schema migration defined for version {new_version}") def _inspect_schema_version(engine, session): diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index d810d50cfbf..5a3223a8508 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -209,7 +209,7 @@ class ScriptEntity(ToggleEntity): await self.script.async_run(kwargs.get(ATTR_VARIABLES), context) except Exception as err: # pylint: disable=broad-except self.script.async_log_exception( - _LOGGER, "Error executing script {}".format(self.entity_id), err + _LOGGER, f"Error executing script {self.entity_id}", err ) raise err diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index c9bd486053e..68561d45f8f 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -198,7 +198,7 @@ async def async_setup(hass, config): return if service.service == "write": logger = logging.getLogger( - service.data.get(CONF_LOGGER, "{}.external".format(__name__)) + service.data.get(CONF_LOGGER, f"{__name__}.external") ) level = service.data[CONF_LEVEL] getattr(logger, level)(service.data[CONF_MESSAGE]) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 77d24fd7aab..3e7900502d6 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -165,9 +165,7 @@ async def async_setup(hass, config): DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True ) - service_name = p_config.get( - CONF_SERVICE_NAME, "{}_{}".format(p_type, SERVICE_SAY) - ) + service_name = p_config.get(CONF_SERVICE_NAME, f"{p_type}_{SERVICE_SAY}") hass.services.async_register( DOMAIN, service_name, async_say_handle, schema=SCHEMA_SERVICE_SAY ) @@ -229,7 +227,7 @@ class SpeechManager: init_tts_cache_dir, cache_dir ) except OSError as err: - raise HomeAssistantError("Can't init cache dir {}".format(err)) + raise HomeAssistantError(f"Can't init cache dir {err}") def get_cache_files(): """Return a dict of given engine files.""" @@ -251,7 +249,7 @@ class SpeechManager: try: cache_files = await self.hass.async_add_job(get_cache_files) except OSError as err: - raise HomeAssistantError("Can't read cache dir {}".format(err)) + raise HomeAssistantError(f"Can't read cache dir {err}") if cache_files: self.file_cache.update(cache_files) @@ -293,7 +291,7 @@ class SpeechManager: # Languages language = language or provider.default_language if language is None or language not in provider.supported_languages: - raise HomeAssistantError("Not supported language {0}".format(language)) + raise HomeAssistantError(f"Not supported language {language}") # Options if provider.default_options and options: @@ -308,9 +306,7 @@ class SpeechManager: if opt_name not in (provider.supported_options or []) ] if invalid_opts: - raise HomeAssistantError( - "Invalid options found: {}".format(invalid_opts) - ) + raise HomeAssistantError(f"Invalid options found: {invalid_opts}") options_key = ctypes.c_size_t(hash(frozenset(options))).value else: options_key = "-" @@ -330,7 +326,7 @@ class SpeechManager: engine, key, message, use_cache, language, options ) - return "{}/api/tts_proxy/{}".format(self.base_url, filename) + return f"{self.base_url}/api/tts_proxy/{filename}" async def async_get_tts_audio(self, engine, key, message, cache, language, options): """Receive TTS and store for view in cache. @@ -341,10 +337,10 @@ class SpeechManager: extension, data = await provider.async_get_tts_audio(message, language, options) if data is None or extension is None: - raise HomeAssistantError("No TTS from {} for '{}'".format(engine, message)) + raise HomeAssistantError(f"No TTS from {engine} for '{message}'") # Create file infos - filename = ("{}.{}".format(key, extension)).lower() + filename = (f"{key}.{extension}").lower() data = self.write_tags(filename, data, provider, message, language, options) @@ -381,7 +377,7 @@ class SpeechManager: """ filename = self.file_cache.get(key) if not filename: - raise HomeAssistantError("Key {} not in file cache!".format(key)) + raise HomeAssistantError(f"Key {key} not in file cache!") voice_file = os.path.join(self.cache_dir, filename) @@ -394,7 +390,7 @@ class SpeechManager: data = await self.hass.async_add_job(load_speech) except OSError: del self.file_cache[key] - raise HomeAssistantError("Can't read {}".format(voice_file)) + raise HomeAssistantError(f"Can't read {voice_file}") self._async_store_to_memcache(key, filename, data) @@ -425,7 +421,7 @@ class SpeechManager: if key not in self.mem_cache: if key not in self.file_cache: - raise HomeAssistantError("{} not in cache!".format(key)) + raise HomeAssistantError(f"{key} not in cache!") await self.async_file_to_mem(key) content, _ = mimetypes.guess_type(filename) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index c09c43dc282..17eacc326d3 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -98,7 +98,7 @@ async def async_setup(hass, config): tariff_confs.append( { CONF_METER: meter, - CONF_NAME: "{} {}".format(meter, tariff), + CONF_NAME: f"{meter} {tariff}", CONF_TARIFF: tariff, } ) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 1eceaea2ae5..1ad4300b28b 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -107,7 +107,7 @@ class UtilityMeterSensor(RestoreEntity): if name: self._name = name else: - self._name = "{} meter".format(source_entity) + self._name = f"{source_entity} meter" self._unit_of_measurement = None self._period = meter_type self._period_offset = meter_offset diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 2ed03b73eff..af107a6ae0d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -33,7 +33,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) def setup(hass, config): """Set up Zeroconf and make Home Assistant discoverable.""" - zeroconf_name = "{}.{}".format(hass.config.location_name, ZEROCONF_TYPE) + zeroconf_name = f"{hass.config.location_name}.{ZEROCONF_TYPE}" params = { "version": __version__, diff --git a/homeassistant/config.py b/homeassistant/config.py index 1f42b3db25e..f4775e71805 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -317,7 +317,7 @@ async def async_hass_config_yaml(hass: HomeAssistant) -> Dict: path = find_config_file(hass.config.config_dir) if path is None: raise HomeAssistantError( - "Config file not found in: {}".format(hass.config.config_dir) + f"Config file not found in: {hass.config.config_dir}" ) config = load_yaml_config_file(path) return config @@ -443,7 +443,7 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: This method must be run in the event loop. """ - message = "Invalid config for [{}]: ".format(domain) + message = f"Invalid config for [{domain}]: " if "extra keys not allowed" in ex.error_message: message += ( "[{option}] is an invalid option for [{domain}]. " @@ -705,7 +705,7 @@ async def merge_packages_config( error = _recursive_merge(conf=config[comp_name], package=comp_conf) if error: _log_pkg_error( - pack_name, comp_name, config, "has duplicate key '{}'".format(error) + pack_name, comp_name, config, f"has duplicate key '{error}'" ) return config @@ -777,7 +777,7 @@ async def async_process_component_config( p_config ) except vol.Invalid as ex: - async_log_exception(ex, "{}.{}".format(domain, p_name), p_config, hass) + async_log_exception(ex, f"{domain}.{p_name}", p_config, hass) continue platforms.append(p_validated) @@ -836,7 +836,7 @@ def async_notify_setup_error( else: part = name - message += " - {}\n".format(part) + message += f" - {part}\n" message += "\nPlease check your config." diff --git a/homeassistant/core.py b/homeassistant/core.py index e8e33a0479e..4d7596d667b 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1365,7 +1365,7 @@ class Config: self.time_zone = time_zone dt_util.set_default_time_zone(time_zone) else: - raise ValueError("Received invalid time zone {}".format(time_zone_str)) + raise ValueError(f"Received invalid time zone {time_zone_str}") @callback def _update( diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 0af6677dceb..6bbd757fca6 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -126,7 +126,7 @@ class FlowManager: self, flow: Any, step_id: str, user_input: Optional[Dict] ) -> Dict: """Handle a step of a flow.""" - method = "async_step_{}".format(step_id) + method = f"async_step_{step_id}" if not hasattr(flow, method): self._progress.pop(flow.flow_id) diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index dfb001ff0d7..89caf730ad7 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -25,7 +25,7 @@ class TemplateError(HomeAssistantError): def __init__(self, exception: jinja2.TemplateError) -> None: """Init the error.""" - super().__init__("{}: {}".format(exception.__class__.__name__, exception)) + super().__init__(f"{exception.__class__.__name__}: {exception}") class PlatformNotReady(HomeAssistantError): @@ -73,10 +73,10 @@ class ServiceNotFound(HomeAssistantError): def __init__(self, domain: str, service: str) -> None: """Initialize error.""" - super().__init__(self, "Service {}.{} not found".format(domain, service)) + super().__init__(self, f"Service {domain}.{service} not found") self.domain = domain self.service = service def __str__(self) -> str: """Return string representation.""" - return "Unable to find service {}/{}".format(self.domain, self.service) + return f"Unable to find service {self.domain}/{self.service}" diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index bc39d5d5720..f49ae976827 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -62,7 +62,7 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> HomeAssistantConfig message = "Package {} setup failed. Component {} {}".format( package, component, message ) - domain = "homeassistant.packages.{}.{}".format(package, component) + domain = f"homeassistant.packages.{package}.{component}" pack_config = core_config[CONF_PACKAGES].get(package, config) result.add_error(message, domain, pack_config) @@ -77,9 +77,9 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> HomeAssistantConfig return result.add_error("File configuration.yaml not found.") config = await hass.async_add_executor_job(load_yaml_config_file, config_path) except FileNotFoundError: - return result.add_error("File not found: {}".format(config_path)) + return result.add_error(f"File not found: {config_path}") except HomeAssistantError as err: - return result.add_error("Error loading {}: {}".format(config_path, err)) + return result.add_error(f"Error loading {config_path}: {err}") finally: yaml_loader.clear_secret_cache() @@ -106,13 +106,13 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> HomeAssistantConfig try: integration = await async_get_integration_with_requirements(hass, domain) except (RequirementsNotFound, loader.IntegrationNotFound) as ex: - result.add_error("Component error: {} - {}".format(domain, ex)) + result.add_error(f"Component error: {domain} - {ex}") continue try: component = integration.get_component() except ImportError as ex: - result.add_error("Component error: {} - {}".format(domain, ex)) + result.add_error(f"Component error: {domain} - {ex}") continue config_schema = getattr(component, "CONFIG_SCHEMA", None) @@ -159,7 +159,7 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> HomeAssistantConfig RequirementsNotFound, ImportError, ) as ex: - result.add_error("Platform error {}.{} - {}".format(domain, p_name, ex)) + result.add_error(f"Platform error {domain}.{p_name} - {ex}") continue # Validate platform specific schema @@ -168,7 +168,7 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> HomeAssistantConfig try: p_validated = platform_schema(p_validated) except vol.Invalid as ex: - _comp_error(ex, "{}.{}".format(domain, p_name), p_validated) + _comp_error(ex, f"{domain}.{p_name}", p_validated) continue platforms.append(p_validated) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index bd96e1bafdb..dc2e46cc6b2 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -243,11 +243,11 @@ class Entity: This method must be run in the event loop. """ if self.hass is None: - raise RuntimeError("Attribute hass is None for {}".format(self)) + raise RuntimeError(f"Attribute hass is None for {self}") if self.entity_id is None: raise NoEntitySpecifiedError( - "No entity id specified for entity {}".format(self.name) + f"No entity id specified for entity {self.name}" ) # update entity data @@ -264,11 +264,11 @@ class Entity: def async_write_ha_state(self): """Write the state to the state machine.""" if self.hass is None: - raise RuntimeError("Attribute hass is None for {}".format(self)) + raise RuntimeError(f"Attribute hass is None for {self}") if self.entity_id is None: raise NoEntitySpecifiedError( - "No entity id specified for entity {}".format(self.name) + f"No entity id specified for entity {self.name}" ) self._async_write_ha_state() diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index b28beeaea72..a9237635702 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -205,7 +205,7 @@ class EntityComponent: async def handle_service(call): """Handle the service.""" - service_name = "{}.{}".format(self.domain, name) + service_name = f"{self.domain}.{name}" await self.hass.helpers.service.entity_service_call( self._platforms.values(), func, call, service_name, required_features ) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 4a6a3038fd0..7d5debd484d 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -133,7 +133,7 @@ class EntityPlatform: current_platform.set(self) logger = self.logger hass = self.hass - full_name = "{}.{}".format(self.domain, self.platform_name) + full_name = f"{self.domain}.{self.platform_name}" logger.info("Setting up %s", full_name) warn_task = hass.loop.call_later( @@ -357,7 +357,7 @@ class EntityPlatform: "Not adding entity %s because it's disabled", entry.name or entity.name - or '"{} {}"'.format(self.platform_name, entity.unique_id), + or f'"{self.platform_name} {entity.unique_id}"', ) return @@ -386,12 +386,12 @@ class EntityPlatform: # Make sure it is valid in case an entity set the value themselves if not valid_entity_id(entity.entity_id): - raise HomeAssistantError("Invalid entity id: {}".format(entity.entity_id)) + raise HomeAssistantError(f"Invalid entity id: {entity.entity_id}") if ( entity.entity_id in self.entities or entity.entity_id in self.hass.states.async_entity_ids(self.domain) ): - msg = "Entity id already exists: {}".format(entity.entity_id) + msg = f"Entity id already exists: {entity.entity_id}" if entity.unique_id is not None: msg += ". Platform {} does not generate unique IDs".format( self.platform_name diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 7d81f62fa1c..3be00c859a7 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -166,9 +166,7 @@ class EntityRegistry: ) entity_id = self.async_generate_entity_id( - domain, - suggested_object_id or "{}_{}".format(platform, unique_id), - known_object_ids, + domain, suggested_object_id or f"{platform}_{unique_id}", known_object_ids ) if ( diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index ffd5918810f..4fb0d94287c 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -58,7 +58,7 @@ async def async_handle( handler = hass.data.get(DATA_KEY, {}).get(intent_type) # type: IntentHandler if handler is None: - raise UnknownIntent("Unknown intent {}".format(intent_type)) + raise UnknownIntent(f"Unknown intent {intent_type}") intent = Intent(hass, platform, intent_type, slots or {}, text_input) @@ -68,13 +68,11 @@ async def async_handle( return result except vol.Invalid as err: _LOGGER.warning("Received invalid slot info for %s: %s", intent_type, err) - raise InvalidSlotInfo( - "Received invalid slot info for {}".format(intent_type) - ) from err + raise InvalidSlotInfo(f"Received invalid slot info for {intent_type}") from err except IntentHandleError: raise except Exception as err: - raise IntentUnexpectedError("Error handling {}".format(intent_type)) from err + raise IntentUnexpectedError(f"Error handling {intent_type}") from err class IntentError(HomeAssistantError): @@ -109,7 +107,7 @@ def async_match_state( state = _fuzzymatch(name, states, lambda state: state.name) if state is None: - raise IntentHandleError("Unable to find an entity called {}".format(name)) + raise IntentHandleError(f"Unable to find an entity called {name}") return state @@ -118,9 +116,7 @@ def async_match_state( def async_test_feature(state: State, feature: int, feature_name: str) -> None: """Test is state supports a feature.""" if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0: - raise IntentHandleError( - "Entity {} does not support {}".format(state.name, feature_name) - ) + raise IntentHandleError(f"Entity {state.name} does not support {feature_name}") class IntentHandler: diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py index 8b32b1355fa..30b428a9e17 100644 --- a/homeassistant/helpers/temperature.py +++ b/homeassistant/helpers/temperature.py @@ -20,7 +20,7 @@ def display_temp( # If the temperature is not a number this can cause issues # with Polymer components, so bail early there. if not isinstance(temperature, Number): - raise TypeError("Temperature is not a number: {}".format(temperature)) + raise TypeError(f"Temperature is not a number: {temperature}") # type ignore: https://github.com/python/mypy/issues/7207 if temperature_unit != ha_unit: # type: ignore diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ca320cb1c33..98e3849bfb6 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -320,10 +320,10 @@ class AllStates: """Return the domain state.""" if "." in name: if not valid_entity_id(name): - raise TemplateError("Invalid entity ID '{}'".format(name)) + raise TemplateError(f"Invalid entity ID '{name}'") return _get_state(self._hass, name) if not valid_entity_id(name + ".entity"): - raise TemplateError("Invalid domain name '{}'".format(name)) + raise TemplateError(f"Invalid domain name '{name}'") return DomainStates(self._hass, name) def _collect_all(self): @@ -367,9 +367,9 @@ class DomainStates: def __getattr__(self, name): """Return the states.""" - entity_id = "{}.{}".format(self._domain, name) + entity_id = f"{self._domain}.{name}" if not valid_entity_id(entity_id): - raise TemplateError("Invalid entity ID '{}'".format(entity_id)) + raise TemplateError(f"Invalid entity ID '{entity_id}'") return _get_state(self._hass, entity_id) def _collect_domain(self): @@ -399,7 +399,7 @@ class DomainStates: def __repr__(self): """Representation of Domain States.""" - return "