From a3e84791c6bed7e841205be76bcd210d7db6234d Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Wed, 8 Apr 2020 11:22:25 -0400 Subject: [PATCH] Convert nws integration to component configuration (#31398) * convert nws to component configuration * add more debug logging * remove assumed state * move initialization of data into init * refactor update logic * use forecast success for checking available entities * Split unique_id into more usable pieces. use a base_unique_id for each entry. Add domain for signaling to entities. Each entity in each platform can use base_unique_id to form individual unique_id's. * Revert "move initialization of data into init" This reverts commit 09eb0220469285b10f0500f5f6def67415931a81. * add silver quality scale to manifest * unsubscribe listener in will_remove_from_hass * initialize _unsub_listener in __init__ * use async_on_remove * remove scan interval from configuration * Use better name Co-Authored-By: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/nws/__init__.py | 177 +++++++++++++++- homeassistant/components/nws/const.py | 54 +++++ homeassistant/components/nws/manifest.json | 3 +- homeassistant/components/nws/weather.py | 231 +++++++-------------- tests/components/nws/conftest.py | 24 +++ tests/components/nws/const.py | 4 + tests/components/nws/test_init.py | 67 ++++++ tests/components/nws/test_weather.py | 209 +++++++++---------- 8 files changed, 496 insertions(+), 273 deletions(-) create mode 100644 homeassistant/components/nws/const.py create mode 100644 tests/components/nws/conftest.py create mode 100644 tests/components/nws/test_init.py diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index dde2f6dee11..1a046c4a05d 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -1 +1,176 @@ -"""NWS Integration.""" +"""The National Weather Service integration.""" +import asyncio +import datetime +import logging + +import aiohttp +from pynws import SimpleNWS +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import CONF_STATION, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_INDIVIDUAL_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): 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_STATION): cv.string, + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [_INDIVIDUAL_SCHEMA])}, extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["weather"] + +DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=10) + + +def base_unique_id(latitude, longitude): + """Return unique id for entries in configuration.""" + return f"{latitude}_{longitude}" + + +def signal_unique_id(latitude, longitude): + """Return unique id for signaling to entries in configuration from component.""" + return f"{DOMAIN}_{base_unique_id(latitude,longitude)}" + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the National Weather Service integration.""" + if DOMAIN not in config: + return True + + hass.data[DOMAIN] = hass.data.get(DOMAIN, {}) + for entry in config[DOMAIN]: + latitude = entry.get(CONF_LATITUDE, hass.config.latitude) + longitude = entry.get(CONF_LONGITUDE, hass.config.longitude) + api_key = entry[CONF_API_KEY] + + client_session = async_get_clientsession(hass) + + if base_unique_id(latitude, longitude) in hass.data[DOMAIN]: + _LOGGER.error( + "Duplicate entry in config: latitude %s latitude: %s", + latitude, + longitude, + ) + continue + + nws_data = NwsData(hass, latitude, longitude, api_key, client_session) + hass.data[DOMAIN][base_unique_id(latitude, longitude)] = nws_data + async_track_time_interval(hass, nws_data.async_update, DEFAULT_SCAN_INTERVAL) + + for component in PLATFORMS: + hass.async_create_task( + discovery.async_load_platform(hass, component, DOMAIN, {}, config) + ) + + return True + + +class NwsData: + """Data class for National Weather Service integration.""" + + def __init__(self, hass, latitude, longitude, api_key, websession): + """Initialize the data.""" + self.hass = hass + self.latitude = latitude + self.longitude = longitude + ha_api_key = f"{api_key} homeassistant" + self.nws = SimpleNWS(latitude, longitude, ha_api_key, websession) + + self.update_observation_success = True + self.update_forecast_success = True + self.update_forecast_hourly_success = True + + async def async_set_station(self, station): + """ + Set to desired station. + + If None, nearest station is used. + """ + await self.nws.set_station(station) + _LOGGER.debug("Nearby station list: %s", self.nws.stations) + + @property + def station(self): + """Return station name.""" + return self.nws.station + + @property + def observation(self): + """Return observation.""" + return self.nws.observation + + @property + def forecast(self): + """Return day+night forecast.""" + return self.nws.forecast + + @property + def forecast_hourly(self): + """Return hourly forecast.""" + return self.nws.forecast_hourly + + @staticmethod + async def _async_update_item(update_call, update_type, station_name, success): + try: + _LOGGER.debug("Updating %s for station %s", update_type, station_name) + await update_call() + + if success: + _LOGGER.warning( + "Success updating %s for station %s", update_type, station_name + ) + success = True + except (aiohttp.ClientError, asyncio.TimeoutError) as err: + if success: + _LOGGER.warning( + "Error updating %s for station %s: %s", + update_type, + station_name, + err, + ) + success = False + + async def async_update(self, now=None): + """Update all data.""" + + await self._async_update_item( + self.nws.update_observation, + "observation", + self.station, + self.update_observation_success, + ) + await self._async_update_item( + self.nws.update_forecast, + "forecast", + self.station, + self.update_forecast_success, + ) + await self._async_update_item( + self.nws.update_forecast_hourly, + "forecast_hourly", + self.station, + self.update_forecast_hourly_success, + ) + + async_dispatcher_send( + self.hass, signal_unique_id(self.latitude, self.longitude) + ) diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py new file mode 100644 index 00000000000..769f927ea71 --- /dev/null +++ b/homeassistant/components/nws/const.py @@ -0,0 +1,54 @@ +"""Constants for National Weather Service Integration.""" +DOMAIN = "nws" + +CONF_STATION = "station" + +ATTRIBUTION = "Data from National Weather Service/NOAA" + +ATTR_FORECAST_DETAILED_DESCRIPTION = "detailed_description" +ATTR_FORECAST_PRECIP_PROB = "precipitation_probability" +ATTR_FORECAST_DAYTIME = "daytime" + +CONDITION_CLASSES = { + "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"], +} diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 17c4c906266..b4b6e402941 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -3,5 +3,6 @@ "name": "National Weather Service (NWS)", "documentation": "https://www.home-assistant.io/integrations/nws", "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==0.10.4"] + "requirements": ["pynws==0.10.4"], + "quality_scale": "silver" } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index a0ce1449479..0c409bef3ca 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,12 +1,8 @@ """Support for NWS weather service.""" -from collections import OrderedDict -from datetime import timedelta -from json import JSONDecodeError +import asyncio import logging import aiohttp -from pynws import SimpleNWS -import voluptuous as vol from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -14,15 +10,11 @@ from homeassistant.components.weather import ( ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, - PLATFORM_SCHEMA, WeatherEntity, ) from homeassistant.const import ( - CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, - CONF_MODE, - CONF_NAME, LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_MILES, @@ -32,110 +24,26 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import Throttle +from homeassistant.helpers.dispatcher import async_dispatcher_connect 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 +from . import base_unique_id, signal_unique_id +from .const import ( + ATTR_FORECAST_DAYTIME, + ATTR_FORECAST_DETAILED_DESCRIPTION, + ATTR_FORECAST_PRECIP_PROB, + ATTRIBUTION, + CONDITION_CLASSES, + CONF_STATION, + DOMAIN, +) + _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): """ @@ -165,85 +73,73 @@ def convert_condition(time, weather): return cond, max(prec_probs) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info): """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, websession) + nws_data = hass.data[DOMAIN][base_unique_id(latitude, longitude)] - _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 - ) + await nws_data.async_set_station(station) + except (aiohttp.ClientError, asyncio.TimeoutError) as err: + _LOGGER.error("Error automatically setting station: %s", str(err)) raise PlatformNotReady - _LOGGER.debug("Station list: %s", nws.stations) - _LOGGER.debug( - "Initialized for coordinates %s, %s -> station %s", - latitude, - longitude, - nws.station, - ) + await nws_data.async_update() - async_add_entities([NWSWeather(nws, mode, hass.config.units, config)], True) + async_add_entities( + [ + NWSWeather(nws_data, "daynight", hass.config.units), + NWSWeather(nws_data, "hourly", hass.config.units), + ], + False, + ) class NWSWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, nws, mode, units, config): + def __init__(self, nws, mode, units): """Initialise the platform with a data instance and station name.""" self.nws = nws - self.station_name = config.get(CONF_NAME, self.nws.station) + self.station = nws.station + self.latitude = nws.latitude + self.longitude = nws.longitude + 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, + async def async_added_to_hass(self) -> None: + """Set up a listener and load data.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + signal_unique_id(self.latitude, self.longitude), + self._update_callback, ) - else: - self.observation = self.nws.observation - _LOGGER.debug("Observation: %s", self.observation) - _LOGGER.debug("Updating forecast") - try: - if self.mode == "daynight": - await self.nws.update_forecast() - else: - await self.nws.update_forecast_hourly() - except ERRORS as status: - _LOGGER.error( - "Error updating forecast from station %s: %s", self.nws.station, status - ) - return + ) + self._update_callback() + + @callback + def _update_callback(self) -> None: + """Load data from integration.""" + self.observation = self.nws.observation if self.mode == "daynight": self._forecast = self.nws.forecast else: self._forecast = self.nws.forecast_hourly - _LOGGER.debug("Forecast: %s", self._forecast) - _LOGGER.debug("Finished updating") + + self.async_schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Entities do not individually poll.""" + return False @property def attribution(self): @@ -253,7 +149,7 @@ class NWSWeather(WeatherEntity): @property def name(self): """Return the name of the station.""" - return self.station_name + return f"{self.station} {self.mode.title()}" @property def temperature(self): @@ -354,7 +250,7 @@ class NWSWeather(WeatherEntity): forecast = [] for forecast_entry in self._forecast: data = { - ATTR_FORECAST_DETAIL_DESCRIPTION: forecast_entry.get( + ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get( "detailedForecast" ), ATTR_FORECAST_TEMP: forecast_entry.get("temperature"), @@ -385,3 +281,20 @@ class NWSWeather(WeatherEntity): data[ATTR_FORECAST_WIND_SPEED] = None forecast.append(data) return forecast + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return f"{base_unique_id(self.latitude, self.longitude)}_{self.mode}" + + @property + def available(self): + """Return if state is available.""" + if self.mode == "daynight": + return ( + self.nws.update_observation_success and self.nws.update_forecast_success + ) + return ( + self.nws.update_observation_success + and self.nws.update_forecast_hourly_success + ) diff --git a/tests/components/nws/conftest.py b/tests/components/nws/conftest.py new file mode 100644 index 00000000000..0f28dec1d26 --- /dev/null +++ b/tests/components/nws/conftest.py @@ -0,0 +1,24 @@ +"""Fixtures for National Weather Service tests.""" +from unittest.mock import patch + +import pytest + +from tests.common import mock_coro +from tests.components.nws.const import DEFAULT_FORECAST, DEFAULT_OBSERVATION + + +@pytest.fixture() +def mock_simple_nws(): + """Mock pynws SimpleNWS with default values.""" + with patch("homeassistant.components.nws.SimpleNWS") as mock_nws: + instance = mock_nws.return_value + instance.set_station.return_value = mock_coro() + instance.update_observation.return_value = mock_coro() + instance.update_forecast.return_value = mock_coro() + instance.update_forecast_hourly.return_value = mock_coro() + instance.station = "ABC" + instance.stations = ["ABC"] + instance.observation = DEFAULT_OBSERVATION + instance.forecast = DEFAULT_FORECAST + instance.forecast_hourly = DEFAULT_FORECAST + yield mock_nws diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index 2a9bf060b73..26490e512f9 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -1,4 +1,5 @@ """Helpers for interacting with pynws.""" +from homeassistant.components.nws.const import DOMAIN from homeassistant.components.nws.weather import ATTR_FORECAST_PRECIP_PROB from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -14,6 +15,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, ) from homeassistant.const import ( + CONF_API_KEY, LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_MILES, @@ -27,6 +29,8 @@ 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 +MINIMAL_CONFIG = {DOMAIN: [{CONF_API_KEY: "test"}]} + DEFAULT_STATIONS = ["ABC", "XYZ"] DEFAULT_OBSERVATION = { diff --git a/tests/components/nws/test_init.py b/tests/components/nws/test_init.py new file mode 100644 index 00000000000..015e4ad81c8 --- /dev/null +++ b/tests/components/nws/test_init.py @@ -0,0 +1,67 @@ +"""Tests for init module.""" +from homeassistant.components import nws +from homeassistant.components.nws.const import CONF_STATION, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component +from tests.components.nws.const import MINIMAL_CONFIG + +LATLON_CONFIG = { + DOMAIN: [{CONF_API_KEY: "test", CONF_LATITUDE: 45.0, CONF_LONGITUDE: -75.0}] +} +FULL_CONFIG = { + DOMAIN: [ + { + CONF_API_KEY: "test", + CONF_LATITUDE: 45.0, + CONF_LONGITUDE: -75.0, + CONF_STATION: "XYZ", + } + ] +} +DUPLICATE_CONFIG = { + DOMAIN: [ + {CONF_API_KEY: "test", CONF_LATITUDE: 45.0, CONF_LONGITUDE: -75.0}, + {CONF_API_KEY: "test", CONF_LATITUDE: 45.0, CONF_LONGITUDE: -75.0}, + ] +} + + +async def test_no_config(hass, mock_simple_nws): + """Test that nws does not setup with no config.""" + with assert_setup_component(0): + assert await async_setup_component(hass, DOMAIN, {}) is True + assert DOMAIN not in hass.data + + +async def test_successful_minimal_config(hass, mock_simple_nws): + """Test that nws setup with minimal config.""" + hass.config.latitude = 40.0 + hass.config.longitude = -75.0 + with assert_setup_component(1): + assert await async_setup_component(hass, DOMAIN, MINIMAL_CONFIG) is True + assert DOMAIN in hass.data + assert nws.base_unique_id(40.0, -75.0) in hass.data[DOMAIN] + + +async def test_successful_latlon_config(hass, mock_simple_nws): + """Test that nws setup with latlon config.""" + with assert_setup_component(1): + assert await async_setup_component(hass, DOMAIN, LATLON_CONFIG) is True + assert DOMAIN in hass.data + assert nws.base_unique_id(45.0, -75.0) in hass.data[DOMAIN] + + +async def test_successful_full_config(hass, mock_simple_nws): + """Test that nws setup with full config.""" + with assert_setup_component(1): + assert await async_setup_component(hass, DOMAIN, FULL_CONFIG) is True + assert DOMAIN in hass.data + assert nws.base_unique_id(45.0, -75.0) in hass.data[DOMAIN] + + +async def test_unsuccessful_duplicate_config(hass, mock_simple_nws): + """Test that nws setup with duplicate config.""" + assert await async_setup_component(hass, DOMAIN, DUPLICATE_CONFIG) is True + assert len(hass.data[DOMAIN]) == 1 diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index f2b390a2235..d0e097326c7 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -1,35 +1,26 @@ """Tests for the NWS weather component.""" -from unittest.mock import patch +from datetime import timedelta import aiohttp import pytest +from homeassistant.components import nws from homeassistant.components.weather import ATTR_FORECAST from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM -from .const import ( - DEFAULT_FORECAST, - DEFAULT_OBSERVATION, +from tests.common import async_fire_time_changed +from tests.components.nws.const import ( EXPECTED_FORECAST_IMPERIAL, EXPECTED_FORECAST_METRIC, EXPECTED_OBSERVATION_IMPERIAL, EXPECTED_OBSERVATION_METRIC, + MINIMAL_CONFIG, NONE_FORECAST, NONE_OBSERVATION, ) -from tests.common import mock_coro - -MINIMAL_CONFIG = { - "weather": { - "platform": "nws", - "api_key": "x@example.com", - "latitude": 40.0, - "longitude": -85.0, - } -} - HOURLY_CONFIG = { "weather": { "platform": "nws", @@ -48,21 +39,29 @@ HOURLY_CONFIG = { (METRIC_SYSTEM, EXPECTED_OBSERVATION_METRIC, EXPECTED_FORECAST_METRIC), ], ) -async def test_imperial_metric(hass, units, result_observation, result_forecast): +async def test_imperial_metric( + hass, units, result_observation, result_forecast, mock_simple_nws +): """Test with imperial and metric units.""" hass.config.units = units - with patch("homeassistant.components.nws.weather.SimpleNWS") as mock_nws: - instance = mock_nws.return_value - instance.station = "ABC" - instance.set_station.return_value = mock_coro() - instance.update_observation.return_value = mock_coro() - instance.update_forecast.return_value = mock_coro() - instance.observation = DEFAULT_OBSERVATION - instance.forecast = DEFAULT_FORECAST + assert await async_setup_component(hass, nws.DOMAIN, MINIMAL_CONFIG) + await hass.async_block_till_done() - await async_setup_component(hass, "weather", MINIMAL_CONFIG) + state = hass.states.get("weather.abc_hourly") + + assert state + assert state.state == "sunny" + + data = state.attributes + for key, value in result_observation.items(): + assert data.get(key) == value + + forecast = data.get(ATTR_FORECAST) + for key, value in result_forecast.items(): + assert forecast[0].get(key) == value + + state = hass.states.get("weather.abc_daynight") - state = hass.states.get("weather.abc") assert state assert state.state == "sunny" @@ -75,50 +74,17 @@ async def test_imperial_metric(hass, units, result_observation, result_forecast) assert forecast[0].get(key) == value -async def test_hourly(hass): - """Test with hourly option.""" - hass.config.units = IMPERIAL_SYSTEM - - with patch("homeassistant.components.nws.weather.SimpleNWS") as mock_nws: - instance = mock_nws.return_value - instance.station = "ABC" - instance.set_station.return_value = mock_coro() - instance.update_observation.return_value = mock_coro() - instance.update_forecast_hourly.return_value = mock_coro() - instance.observation = DEFAULT_OBSERVATION - instance.forecast_hourly = DEFAULT_FORECAST - - await async_setup_component(hass, "weather", HOURLY_CONFIG) - - state = hass.states.get("weather.abc") - assert state - assert state.state == "sunny" - - data = state.attributes - for key, value in EXPECTED_OBSERVATION_IMPERIAL.items(): - assert data.get(key) == value - - forecast = data.get(ATTR_FORECAST) - for key, value in EXPECTED_FORECAST_IMPERIAL.items(): - assert forecast[0].get(key) == value - - -async def test_none_values(hass): +async def test_none_values(hass, mock_simple_nws): """Test with none values in observation and forecast dicts.""" - with patch("homeassistant.components.nws.weather.SimpleNWS") as mock_nws: - instance = mock_nws.return_value - instance.station = "ABC" - instance.set_station.return_value = mock_coro() - instance.update_observation.return_value = mock_coro() - instance.update_forecast.return_value = mock_coro() - instance.observation = NONE_OBSERVATION - instance.forecast = NONE_FORECAST - await async_setup_component(hass, "weather", MINIMAL_CONFIG) + instance = mock_simple_nws.return_value + instance.observation = NONE_OBSERVATION + instance.forecast = NONE_FORECAST - state = hass.states.get("weather.abc") - assert state + assert await async_setup_component(hass, nws.DOMAIN, MINIMAL_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("weather.abc_daynight") assert state.state == "unknown" - data = state.attributes for key in EXPECTED_OBSERVATION_IMPERIAL: assert data.get(key) is None @@ -128,19 +94,16 @@ async def test_none_values(hass): assert forecast[0].get(key) is None -async def test_none(hass): +async def test_none(hass, mock_simple_nws): """Test with None as observation and forecast.""" - with patch("homeassistant.components.nws.weather.SimpleNWS") as mock_nws: - instance = mock_nws.return_value - instance.station = "ABC" - instance.set_station.return_value = mock_coro() - instance.update_observation.return_value = mock_coro() - instance.update_forecast.return_value = mock_coro() - instance.observation = None - instance.forecast = None - await async_setup_component(hass, "weather", MINIMAL_CONFIG) + instance = mock_simple_nws.return_value + instance.observation = None + instance.forecast = None - state = hass.states.get("weather.abc") + assert await async_setup_component(hass, nws.DOMAIN, MINIMAL_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("weather.abc_daynight") assert state assert state.state == "unknown" @@ -152,46 +115,68 @@ async def test_none(hass): assert forecast is None -async def test_error_station(hass): +async def test_error_station(hass, mock_simple_nws): """Test error in setting station.""" - with patch("homeassistant.components.nws.weather.SimpleNWS") as mock_nws: - instance = mock_nws.return_value - instance.station = "ABC" - instance.set_station.side_effect = aiohttp.ClientError - instance.update_observation.return_value = mock_coro() - instance.update_forecast.return_value = mock_coro() - instance.observation = None - instance.forecast = None - await async_setup_component(hass, "weather", MINIMAL_CONFIG) - state = hass.states.get("weather.abc") - assert state is None + instance = mock_simple_nws.return_value + instance.set_station.side_effect = aiohttp.ClientError + + assert await async_setup_component(hass, nws.DOMAIN, MINIMAL_CONFIG) is True + await hass.async_block_till_done() + + assert hass.states.get("weather.abc_hourly") is None + assert hass.states.get("weather.abc_daynight") is None -async def test_error_observation(hass, caplog): +async def test_error_observation(hass, mock_simple_nws, caplog): """Test error during update observation.""" - with patch("homeassistant.components.nws.weather.SimpleNWS") as mock_nws: - instance = mock_nws.return_value - instance.station = "ABC" - instance.set_station.return_value = mock_coro() - instance.update_observation.side_effect = aiohttp.ClientError - instance.update_forecast.return_value = mock_coro() - instance.observation = None - instance.forecast = None - await async_setup_component(hass, "weather", MINIMAL_CONFIG) + instance = mock_simple_nws.return_value + instance.update_observation.side_effect = aiohttp.ClientError - assert "Error updating observation from station ABC" in caplog.text + assert await async_setup_component(hass, nws.DOMAIN, MINIMAL_CONFIG) + await hass.async_block_till_done() + + instance.update_observation.side_effect = None + + future_time = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future_time) + await hass.async_block_till_done() + + assert "Error updating observation" in caplog.text + assert "Success updating observation" in caplog.text -async def test_error_forecast(hass, caplog): +async def test_error_forecast(hass, caplog, mock_simple_nws): """Test error during update forecast.""" - with patch("homeassistant.components.nws.weather.SimpleNWS") as mock_nws: - instance = mock_nws.return_value - instance.station = "ABC" - instance.set_station.return_value = mock_coro() - instance.update_observation.return_value = mock_coro() - instance.update_forecast.side_effect = aiohttp.ClientError - instance.observation = None - instance.forecast = None - await async_setup_component(hass, "weather", MINIMAL_CONFIG) - assert "Error updating forecast from station ABC" in caplog.text + instance = mock_simple_nws.return_value + instance.update_forecast.side_effect = aiohttp.ClientError + + assert await async_setup_component(hass, nws.DOMAIN, MINIMAL_CONFIG) + await hass.async_block_till_done() + + instance.update_forecast.side_effect = None + + future_time = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future_time) + await hass.async_block_till_done() + + assert "Error updating forecast" in caplog.text + assert "Success updating forecast" in caplog.text + + +async def test_error_forecast_hourly(hass, caplog, mock_simple_nws): + """Test error during update forecast hourly.""" + instance = mock_simple_nws.return_value + instance.update_forecast_hourly.side_effect = aiohttp.ClientError + + assert await async_setup_component(hass, nws.DOMAIN, MINIMAL_CONFIG) + await hass.async_block_till_done() + + instance.update_forecast_hourly.side_effect = None + + future_time = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future_time) + await hass.async_block_till_done() + + assert "Error updating forecast_hourly" in caplog.text + assert "Success updating forecast_hourly" in caplog.text