From 86fc31235237b832b5717622d6896edd386c8b8e Mon Sep 17 00:00:00 2001 From: ktdad Date: Fri, 3 May 2019 06:49:01 -0400 Subject: [PATCH 01/36] Add nws weather. --- homeassistant/components/nws/__init__.py | 1 + homeassistant/components/nws/manifest.json | 8 + homeassistant/components/nws/weather.py | 306 ++++++++++++++++++++ requirements_all.txt | 3 + tests/components/nws/test_nws.py | 308 +++++++++++++++++++++ 5 files changed, 626 insertions(+) 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_nws.py diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py new file mode 100644 index 00000000000000..dde2f6dee11c52 --- /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 00000000000000..7cb79ed8d8e76c --- /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.6"] +} diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py new file mode 100644 index 00000000000000..532b4f48cdb75b --- /dev/null +++ b/homeassistant/components/nws/weather.py @@ -0,0 +1,306 @@ +"""Support for NWS weather service.""" +from collections import OrderedDict +from datetime import timedelta +import logging +from statistics import mean + +import async_timeout +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.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 + +REQUIREMENTS = ['pynws==0.6'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = 'Data from National Weather Service/NOAA' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) + +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. +# Known NWS conditions that do not map: cold +CONDITION_CLASSES = OrderedDict([ + ('snowy', ['snow', 'snow_sleet', 'sleet', 'blizzard']), + ('snowy-rainy', ['rain_snow', 'rain_sleet', 'fzra', + 'rain_fzra', 'snow_fzra']), + ('hail', []), + ('lightning-rainy', ['tsra', 'tsra_sct', 'tsra_hi']), + ('lightning', []), + ('pouring', []), + ('rainy', ['rain', 'rain_showers', 'rain_showers_hi']), + ('windy-variant', ['wind_bkn', 'wind_ovc']), + ('windy', ['wind_skc', 'wind_few', 'wind_sct']), + ('fog', ['fog']), + ('clear', ['skc']), # sunny and clear-night + ('cloudy', ['bkn', 'ovc']), + ('partlycloudy', ['few', 'sct']) +]) + +FORECAST_CLASSES = { + ATTR_FORECAST_DETAIL_DESCRIPTION: 'detailedForecast', + ATTR_FORECAST_TEMP: 'temperature', + ATTR_FORECAST_TIME: 'startTime', +} + +FORECAST_MODE = ['daynight', 'hourly'] + +WIND_DIRECTIONS = ['N', 'NNE', 'NE', 'ENE', + 'E', 'ESE', 'SE', 'SSE', + 'S', 'SSW', 'SW', 'WSW', + 'W', 'WNW', 'NW', 'NNW'] + +WIND = {name: idx * 360 / 16 for idx, name in enumerate(WIND_DIRECTIONS)} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MODE, default='daynight'): vol.In(FORECAST_MODE), + vol.Optional(CONF_STATION, default=''): cv.string, + vol.Required(CONF_API_KEY): cv.string +}) + + +def parse_icon(icon): + """ + Parse icon url to NWS weather codes. + + Example: + https://api.weather.gov/icons/land/day/skc/tsra,40/ovc?size=medium + + Example return: + ('day', (('skc', 0), ('tsra', 40),)) + """ + icon_list = icon.split('/') + time = icon_list[5] + weather = [i.split('?')[0] for i in icon_list[6:]] + code = [w.split(',')[0] for w in weather] + chance = [int(w.split(',')[1]) if len(w.split(',')) == 2 else 0 + for w in weather] + return time, tuple(zip(code, chance)) + + +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 fitst condition from NWS + """ + conditions = [w[0] for w in weather] + prec_prob = [w[1] 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_prob) + if time == 'night': + return 'clear-night', max(prec_prob) + return cond, max(prec_prob) + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up the nws 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.get(CONF_API_KEY) + + if None in (latitude, longitude): + _LOGGER.error("Latitude/longitude not set in Home Assistant config") + return + + from pynws import Nws + + websession = async_get_clientsession(hass) + # ID request as being from HA, pynws prepends the api_key in addition + api_key_ha = [api_key + 'homeassistant'] + nws = Nws(websession, latlon=(float(latitude), float(longitude)), + userid=api_key_ha) + + _LOGGER.debug("Setting up station: %s", station) + if station == '': + with async_timeout.timeout(10, loop=hass.loop): + stations = await nws.stations() + _LOGGER.info("Station list: %s", stations) + nws.station = stations[0] + _LOGGER.debug("Initialized for coordinates %s, %s -> station %s", + latitude, longitude, stations[0]) + else: + nws.station = station + _LOGGER.debug("Initialized station %s", station[0]) + + async_add_entities([NWSWeather(nws, hass.config.units, config)], True) + + +class NWSWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, nws, 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._observation = None + self._forecast = None + self._description = None + self._is_metric = units.is_metric + self._mode = config[CONF_MODE] + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update Condition.""" + with async_timeout.timeout(10, loop=self.hass.loop): + _LOGGER.debug("Updating station observations %s", + self._nws.station) + self._observation = await self._nws.observations() + _LOGGER.debug("Updating forecast") + if self._mode == 'daynight': + self._forecast = await self._nws.forecast() + elif self._mode == 'hourly': + self._forecast = await self._nws.forecast_hourly() + else: + _LOGGER.error("Invalid Forecast Mode") + _LOGGER.debug("Observations: %s", self._observation) + _LOGGER.debug("Forecasts: %s", self._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 = self._observation[0]['temperature']['value'] + if temp_c is not None: + return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) + return None + + @property + def pressure(self): + """Return the current pressure.""" + pressure_pa = self._observation[0]['seaLevelPressure']['value'] + # convert Pa to in Hg + 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.""" + return self._observation[0]['relativeHumidity']['value'] + + @property + def wind_speed(self): + """Return the current windspeed.""" + wind_m_s = self._observation[0]['windSpeed']['value'] + 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).""" + return self._observation[0]['windDirection']['value'] + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT + + @property + def condition(self): + """Return current condition.""" + time, weather = parse_icon(self._observation[0]['icon']) + cond, _ = convert_condition(time, weather) + return cond + + @property + def visibility(self): + """Return visibility.""" + vis_m = self._observation[0]['visibility']['value'] + 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.""" + forecast = [] + for forecast_entry in self._forecast: + data = {attr: forecast_entry[name] + for attr, name in FORECAST_CLASSES.items()} + if self._mode == 'daynight': + data[ATTR_FORECAST_DAYTIME] = forecast_entry['isDaytime'] + time, weather = parse_icon(forecast_entry['icon']) + cond, precip = convert_condition(time, weather) + data[ATTR_FORECAST_CONDITION] = cond + if precip > 0: + data[ATTR_FORECAST_PRECIP_PROB] = precip + else: + data[ATTR_FORECAST_PRECIP_PROB] = None + data[ATTR_FORECAST_WIND_BEARING] = \ + WIND[forecast_entry['windDirection']] + + # wind speed reported as '7 mph' or '7 to 10 mph' + # if range, take average + wind_speed = forecast_entry['windSpeed'].split(' ')[0::2] + wind_speed_avg = mean(int(w) for w in wind_speed) + if self._is_metric: + data[ATTR_FORECAST_WIND_SPEED] = round( + convert_distance(wind_speed_avg, + LENGTH_MILES, LENGTH_KILOMETERS)) + else: + data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed_avg) + + forecast.append(data) + return forecast diff --git a/requirements_all.txt b/requirements_all.txt index 3365c248501e15..1a995dbe877ed0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1288,6 +1288,9 @@ pynuki==1.3.3 # homeassistant.components.nut pynut2==2.1.2 +# homeassistant.components.nws +pynws==0.6 + # homeassistant.components.nx584 pynx584==0.4 diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py new file mode 100644 index 00000000000000..ae78a2908715e5 --- /dev/null +++ b/tests/components/nws/test_nws.py @@ -0,0 +1,308 @@ +"""Tests for the NWS weather component.""" +import unittest +from unittest.mock import patch + +from homeassistant.components import weather +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, PRECISION_WHOLE, + PRESSURE_INHG, PRESSURE_PA, PRESSURE_HPA, TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.helpers.temperature import display_temp +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 setup_component + +from tests.common import get_test_home_assistant, MockDependency + + +OBS = [{ + 'temperature': {'value': 7, 'qualityControl': 'qc:V'}, + 'relativeHumidity': {'value': 10, 'qualityControl': 'qc:V'}, + 'windChill': {'value': 10, 'qualityControl': 'qc:V'}, + 'heatIndex': {'value': 10, 'qualityControl': 'qc:V'}, + 'windDirection': {'value': 180, 'qualityControl': 'qc:V'}, + 'visibility': {'value': 10000, 'qualityControl': 'qc:V'}, + 'windSpeed': {'value': 10, 'qualityControl': 'qc:V'}, + 'seaLevelPressure': {'value': 30000, 'qualityControl': 'qc:V'}, + 'windGust': {'value': 10, 'qualityControl': 'qc:V'}, + 'dewpoint': {'value': 10, 'qualityControl': 'qc:V'}, + 'icon': 'https://api.weather.gov/icons/land/day/skc?size=medium', + 'textDescription': 'Sunny' +}] + +FORE = [{ + 'endTime': '2018-12-21T18:00:00-05:00', + 'windSpeed': '8 to 10 mph', + 'windDirection': 'S', + 'shortForecast': 'Chance Showers And Thunderstorms', + 'isDaytime': True, + 'startTime': '2018-12-21T15:00:00-05:00', + 'temperatureTrend': None, + 'temperature': 41, + 'temperatureUnit': 'F', + 'detailedForecast': 'A detailed description', + 'name': 'This Afternoon', + 'number': 1, + 'icon': 'https://api.weather.gov/icons/land/day/skc/tsra,40?size=medium' +}] + +HOURLY_FORE = [{ + 'endTime': '2018-12-22T05:00:00-05:00', + 'windSpeed': '4 mph', + 'windDirection': 'N', + 'shortForecast': 'Chance Showers And Thunderstorms', + 'startTime': '2018-12-22T04:00:00-05:00', + 'temperatureTrend': None, + 'temperature': 32, + 'temperatureUnit': 'F', + 'detailedForecast': '', + 'number': 2, + 'icon': 'https://api.weather.gov/icons/land/night/skc?size=medium' +}] + +STN = 'STNA' + + +class MockNws(): + """Mock Station from pynws.""" + + def __init__(self, websession, latlon, userid): + """Init mock nws.""" + pass + + async def observations(self): + """Mock Observation.""" + return OBS + + async def forecast(self): + """Mock Forecast.""" + return FORE + + async def forecast_hourly(self): + """Mock Hourly Forecast.""" + return HOURLY_FORE + + async def stations(self): + """Mock stations.""" + return [STN] + + +class TestNWS(unittest.TestCase): + """Test the NWS weather component.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = IMPERIAL_SYSTEM + self.lat = self.hass.config.latitude = 40.00 + self.lon = self.hass.config.longitude = -8.00 + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_w_name(self, mock_pynws): + """Test for successfully setting up the NWS platform with name.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeWeather', + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + + state = self.hass.states.get('weather.homeweather') + assert state.state == 'sunny' + + data = state.attributes + temp_f = convert_temperature(7, TEMP_CELSIUS, TEMP_FAHRENHEIT) + assert data.get(ATTR_WEATHER_TEMPERATURE) == \ + display_temp(self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE) + assert data.get(ATTR_WEATHER_HUMIDITY) == 10 + assert data.get(ATTR_WEATHER_PRESSURE) == round( + convert_pressure(30000, PRESSURE_PA, PRESSURE_INHG), 2) + assert data.get(ATTR_WEATHER_WIND_SPEED) == round(10 * 2.237) + assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 + assert data.get(ATTR_WEATHER_VISIBILITY) == round( + convert_distance(10000, LENGTH_METERS, LENGTH_MILES)) + assert state.attributes.get('friendly_name') == 'HomeWeather' + + forecast = data.get(ATTR_FORECAST) + assert forecast[0].get(ATTR_FORECAST_CONDITION) == 'lightning-rainy' + assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) == 40 + assert forecast[0].get(ATTR_FORECAST_TEMP) == 41 + assert forecast[0].get(ATTR_FORECAST_TIME) == \ + '2018-12-21T15:00:00-05:00' + assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 + assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == 9 + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_w_station(self, mock_pynws): + """Test for successfully setting up the NWS platform with station.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'platform': 'nws', + 'station': 'STNB', + 'api_key': 'test_email', + } + }) + + assert self.hass.states.get('weather.stnb') + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_w_no_name(self, mock_pynws): + """Test for successfully setting up the NWS platform w no name.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + + assert self.hass.states.get('weather.' + STN) + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test__hourly(self, mock_pynws): + """Test for successfully setting up hourly forecast.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HourlyWeather', + 'platform': 'nws', + 'api_key': 'test_email', + 'mode': 'hourly', + } + }) + + state = self.hass.states.get('weather.hourlyweather') + data = state.attributes + + forecast = data.get(ATTR_FORECAST) + assert forecast[0].get(ATTR_FORECAST_CONDITION) == 'clear-night' + assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) is None + assert forecast[0].get(ATTR_FORECAST_TEMP) == 32 + assert forecast[0].get(ATTR_FORECAST_TIME) == \ + '2018-12-22T04:00:00-05:00' + assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 0 + assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == 4 + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_daynight(self, mock_pynws): + """Test for successfully setting up daynight forecast.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'platform': 'nws', + 'api_key': 'test_email', + 'mode': 'daynight', + } + }) + assert self.hass.states.get('weather.' + STN) + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_latlon(self, mock_pynws): + """Test for successfully setting up the NWS platform with lat/lon.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'platform': 'nws', + 'api_key': 'test_email', + 'latitude': self.lat, + 'longitude': self.lon, + } + }) + assert self.hass.states.get('weather.' + STN) + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_setup_failure_mode(self, mock_pynws): + """Test for unsuccessfully setting up incorrect mode.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'platform': 'nws', + 'api_key': 'test_email', + 'mode': 'abc', + } + }) + assert self.hass.states.get('weather.' + STN) is None + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_setup_failure_no_apikey(self, mock_pynws): + """Test for unsuccessfully setting up without api_key.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'platform': 'nws', + } + }) + + assert self.hass.states.get('weather.' + STN) is None + + +class TestNwsMetric(unittest.TestCase): + """Test the NWS weather component using metric units.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + self.lat = self.hass.config.latitude = 40.00 + self.lon = self.hass.config.longitude = -8.00 + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_metric(self, mock_pynws): + """Test for successfully setting up the NWS platform with name.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeWeather', + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + + state = self.hass.states.get('weather.homeweather') + assert state.state == 'sunny' + + data = state.attributes + assert data.get(ATTR_WEATHER_TEMPERATURE) == \ + display_temp(self.hass, 7, TEMP_CELSIUS, PRECISION_WHOLE) + + assert data.get(ATTR_WEATHER_HUMIDITY) == 10 + assert data.get(ATTR_WEATHER_PRESSURE) == round( + convert_pressure(30000, PRESSURE_PA, PRESSURE_HPA)) + # m/s to km/hr + assert data.get(ATTR_WEATHER_WIND_SPEED) == round(10 * 3.6) + assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 + assert data.get(ATTR_WEATHER_VISIBILITY) == round( + convert_distance(10000, LENGTH_METERS, LENGTH_KILOMETERS)) + assert state.attributes.get('friendly_name') == 'HomeWeather' + + forecast = data.get(ATTR_FORECAST) + assert forecast[0].get(ATTR_FORECAST_CONDITION) == 'lightning-rainy' + assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) == 40 + assert forecast[0].get(ATTR_FORECAST_TEMP) == round( + convert_temperature(41, TEMP_FAHRENHEIT, TEMP_CELSIUS)) + assert forecast[0].get(ATTR_FORECAST_TIME) == \ + '2018-12-21T15:00:00-05:00' + assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 + assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == round( + convert_distance(9, LENGTH_MILES, LENGTH_KILOMETERS)) From 47b8b739ed978b9faaf32791716ff093b1da091d Mon Sep 17 00:00:00 2001 From: ktdad Date: Fri, 3 May 2019 07:26:48 -0400 Subject: [PATCH 02/36] Hassfest --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index 1369517fb6b80b..d9b485a3c19951 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -187,6 +187,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 From 892b8c2bc32463006fbef574348cbae4ba1482ff Mon Sep 17 00:00:00 2001 From: ktdad Date: Sat, 4 May 2019 11:06:23 -0400 Subject: [PATCH 03/36] Address multiple comments --- homeassistant/components/nws/weather.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 532b4f48cdb75b..d14fae107b0fc2 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -75,7 +75,7 @@ vol.Optional(CONF_LATITUDE): cv.latitude, vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_MODE, default='daynight'): vol.In(FORECAST_MODE), - vol.Optional(CONF_STATION, default=''): cv.string, + vol.Optional(CONF_STATION): cv.string, vol.Required(CONF_API_KEY): cv.string }) @@ -85,7 +85,7 @@ def parse_icon(icon): Parse icon url to NWS weather codes. Example: - https://api.weather.gov/icons/land/day/skc/tsra,40/ovc?size=medium + https://api.weather.gov/icons/land/day/skc/tsra,40?size=medium Example return: ('day', (('skc', 0), ('tsra', 40),)) @@ -124,29 +124,29 @@ def convert_condition(time, weather): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Set up the nws platform.""" + """Set up the NWS weather platform.""" + from pynws import Nws + latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) station = config.get(CONF_STATION) - api_key = config.get(CONF_API_KEY) + api_key = config[CONF_API_KEY] if None in (latitude, longitude): _LOGGER.error("Latitude/longitude not set in Home Assistant config") return - from pynws import Nws - websession = async_get_clientsession(hass) # ID request as being from HA, pynws prepends the api_key in addition - api_key_ha = [api_key + 'homeassistant'] + api_key_ha = '{} {}'.format(api_key, 'homeassistant') nws = Nws(websession, latlon=(float(latitude), float(longitude)), userid=api_key_ha) _LOGGER.debug("Setting up station: %s", station) - if station == '': + if station is None: with async_timeout.timeout(10, loop=hass.loop): stations = await nws.stations() - _LOGGER.info("Station list: %s", stations) + _LOGGER.debug("Station list: %s", stations) nws.station = stations[0] _LOGGER.debug("Initialized for coordinates %s, %s -> station %s", latitude, longitude, stations[0]) @@ -182,8 +182,6 @@ async def async_update(self): self._forecast = await self._nws.forecast() elif self._mode == 'hourly': self._forecast = await self._nws.forecast_hourly() - else: - _LOGGER.error("Invalid Forecast Mode") _LOGGER.debug("Observations: %s", self._observation) _LOGGER.debug("Forecasts: %s", self._forecast) From 1f437a364ba36a3a799150fa8e12a5bb8660e7f7 Mon Sep 17 00:00:00 2001 From: ktdad Date: Sat, 4 May 2019 11:26:16 -0400 Subject: [PATCH 04/36] Add NWS icon weather code link --- homeassistant/components/nws/weather.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index d14fae107b0fc2..e3159803ddc1d8 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -37,7 +37,8 @@ ATTR_FORECAST_DAYTIME = 'daytime' # Ordered so that a single condition can be chosen from multiple weather codes. -# Known NWS conditions that do not map: cold +# Catalog of NWS icon weather codes listed at: +# https://api.weather.gov/icons CONDITION_CLASSES = OrderedDict([ ('snowy', ['snow', 'snow_sleet', 'sleet', 'blizzard']), ('snowy-rainy', ['rain_snow', 'rain_sleet', 'fzra', From 1e8bae264a650cb1dac395d73faf98307204a497 Mon Sep 17 00:00:00 2001 From: ktdad Date: Sun, 19 May 2019 07:41:30 -0400 Subject: [PATCH 05/36] Add metar fallback. Use metar code from nws observation if normal api is missing data. --- homeassistant/components/nws/manifest.json | 5 +- homeassistant/components/nws/weather.py | 40 +++++++--- requirements_all.txt | 3 + tests/components/nws/test_nws.py | 86 ++++++++++++++++++++++ 4 files changed, 124 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 7cb79ed8d8e76c..58d735602de2a7 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -4,5 +4,8 @@ "documentation": "https://www.home-assistant.io/components/nws", "dependencies": [], "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==0.6"] + "requirements": [ + "pynws==0.6", + "metar==1.7.0" + ] } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index e3159803ddc1d8..896449819e5f8e 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -22,8 +22,6 @@ from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.temperature import convert as convert_temperature -REQUIREMENTS = ['pynws==0.6'] - _LOGGER = logging.getLogger(__name__) ATTRIBUTION = 'Data from National Weather Service/NOAA' @@ -127,7 +125,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the NWS weather platform.""" from pynws import Nws - + from metar import Metar latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) station = config.get(CONF_STATION) @@ -155,16 +153,21 @@ async def async_setup_platform(hass, config, async_add_entities, nws.station = station _LOGGER.debug("Initialized station %s", station[0]) - async_add_entities([NWSWeather(nws, hass.config.units, config)], True) + async_add_entities( + [NWSWeather(nws, Metar.Metar, hass.config.units, config)], + True) class NWSWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, nws, units, config): + def __init__(self, nws, metar, units, config): """Initialise the platform with a data instance and station name.""" self._nws = nws + self._metar = metar self._station_name = config.get(CONF_NAME, self._nws.station) + + self._metar_obs = None self._observation = None self._forecast = None self._description = None @@ -178,6 +181,11 @@ async def async_update(self): _LOGGER.debug("Updating station observations %s", self._nws.station) self._observation = await self._nws.observations() + self._metar_obs = [ + self._metar(obs['rawMessage']) + for obs in self._observation + if 'rawMessage' in obs.keys() + ] _LOGGER.debug("Updating forecast") if self._mode == 'daynight': self._forecast = await self._nws.forecast() @@ -200,6 +208,8 @@ def name(self): def temperature(self): """Return the current temperature.""" temp_c = self._observation[0]['temperature']['value'] + if temp_c is None and self._metar_obs: + temp_c = self._metar_obs[0].temp.value(units='C') if temp_c is not None: return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) return None @@ -208,9 +218,13 @@ def temperature(self): def pressure(self): """Return the current pressure.""" pressure_pa = self._observation[0]['seaLevelPressure']['value'] - # convert Pa to in Hg - if pressure_pa is None: - return None + + if pressure_pa is None and self._metar_obs: + pressure_hpa = self._metar_obs[0].press.value(units='HPA') + if pressure_hpa is None: + return None + pressure_pa = convert_pressure(pressure_hpa, PRESSURE_HPA, + PRESSURE_PA) if self._is_metric: pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA) @@ -230,6 +244,9 @@ def humidity(self): def wind_speed(self): """Return the current windspeed.""" wind_m_s = self._observation[0]['windSpeed']['value'] + if wind_m_s is None and self._metar_obs: + wind_m_s = self._metar_obs[0].wind_speed.value(units='MPS') + print(wind_m_s) if wind_m_s is None: return None wind_m_hr = wind_m_s * 3600 @@ -244,7 +261,10 @@ def wind_speed(self): @property def wind_bearing(self): """Return the current wind bearing (degrees).""" - return self._observation[0]['windDirection']['value'] + wind_bearing = self._observation[0]['windDirection']['value'] + if wind_bearing is None and self._metar_obs: + wind_bearing = self._metar_obs[0].wind_dir.value() + return wind_bearing @property def temperature_unit(self): @@ -262,6 +282,8 @@ def condition(self): def visibility(self): """Return visibility.""" vis_m = self._observation[0]['visibility']['value'] + if vis_m is None and self._metar_obs: + vis_m = self._metar_obs[0].vis.value(units='M') if vis_m is None: return None diff --git a/requirements_all.txt b/requirements_all.txt index 1a995dbe877ed0..e89f26bb1b745e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -765,6 +765,9 @@ mbddns==0.1.2 # homeassistant.components.message_bird messagebird==1.2.0 +# homeassistant.components.nws +metar==1.7.0 + # homeassistant.components.meteoalarm meteoalertapi==0.1.5 diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index ae78a2908715e5..8cd73e777fe58a 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -40,6 +40,21 @@ 'textDescription': 'Sunny' }] +METAR_MSG = ("PHNG 182257Z 06012KT 10SM FEW020 SCT026 SCT035 " + "28/22 A3007 RMK AO2 SLP177 T02780217") + +OBS_METAR = [{ + "rawMessage": METAR_MSG, + "textDescription": "Partly Cloudy", + "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", + "temperature": {"value": None, "qualityControl": "qc:Z"}, + "windDirection": {"value": None, "qualityControl": "qc:Z"}, + "windSpeed": {"value": None, "qualityControl": "qc:Z"}, + "seaLevelPressure": {"value": None, "qualityControl": "qc:Z"}, + "visibility": {"value": None, "qualityControl": "qc:Z"}, + "relativeHumidity": {"value": None, "qualityControl": "qc:Z"}, +}] + FORE = [{ 'endTime': '2018-12-21T18:00:00-05:00', 'windSpeed': '8 to 10 mph', @@ -306,3 +321,74 @@ def test_metric(self, mock_pynws): assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == round( convert_distance(9, LENGTH_MILES, LENGTH_KILOMETERS)) + + +class MockNws_Metar(): + """Mock Station from pynws.""" + + def __init__(self, websession, latlon, userid): + """Init mock nws.""" + pass + + async def observations(self): + """Mock Observation.""" + return OBS_METAR + + async def forecast(self): + """Mock Forecast.""" + return FORE + + async def forecast_hourly(self): + """Mock Hourly Forecast.""" + return HOURLY_FORE + + async def stations(self): + """Mock stations.""" + return [STN] + + +class TestNWS_Metar(unittest.TestCase): + """Test the NWS weather component.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = IMPERIAL_SYSTEM + self.lat = self.hass.config.latitude = 40.00 + self.lon = self.hass.config.longitude = -8.00 + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws_Metar) + def test_metar(self, mock_pynws): + """Test for successfully setting up the NWS platform with name.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeWeather', + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + + from metar import Metar + truth = Metar.Metar(METAR_MSG) + state = self.hass.states.get('weather.homeweather') + data = state.attributes + + temp_f = convert_temperature(truth.temp.value(), TEMP_CELSIUS, + TEMP_FAHRENHEIT) + assert data.get(ATTR_WEATHER_TEMPERATURE) == \ + display_temp(self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE) + assert data.get(ATTR_WEATHER_HUMIDITY) is None + assert data.get(ATTR_WEATHER_PRESSURE) == round( + convert_pressure(truth.press.value(units='HPA'), PRESSURE_HPA, + PRESSURE_INHG), + 2) + assert data.get(ATTR_WEATHER_WIND_SPEED) == round( + truth.wind_speed.value(units='MPH')) + assert data.get(ATTR_WEATHER_WIND_BEARING) == truth.wind_dir.value() + assert data.get(ATTR_WEATHER_VISIBILITY) == round( + truth.vis.value(units='MI')) From d2867844f40081d14cdd201772a4a5ef0f2abc99 Mon Sep 17 00:00:00 2001 From: ktdad Date: Sun, 19 May 2019 20:05:46 -0400 Subject: [PATCH 06/36] only get 1 observation - we dont use more than 1 --- homeassistant/components/nws/weather.py | 38 +++++++++++++------------ tests/components/nws/test_nws.py | 4 +-- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 896449819e5f8e..f19be3694889a6 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -180,12 +180,14 @@ async def async_update(self): with async_timeout.timeout(10, loop=self.hass.loop): _LOGGER.debug("Updating station observations %s", self._nws.station) - self._observation = await self._nws.observations() - self._metar_obs = [ - self._metar(obs['rawMessage']) - for obs in self._observation - if 'rawMessage' in obs.keys() - ] + + obs = await self._nws.observations(limit=1) + self._observation = obs[0] + if 'rawMessage' in self._observation.keys(): + self._metar_obs = self._metar(self._observation['rawMessage']) + else: + self._metar_obs = None + _LOGGER.debug("Updating forecast") if self._mode == 'daynight': self._forecast = await self._nws.forecast() @@ -207,9 +209,9 @@ def name(self): @property def temperature(self): """Return the current temperature.""" - temp_c = self._observation[0]['temperature']['value'] + temp_c = self._observation['temperature']['value'] if temp_c is None and self._metar_obs: - temp_c = self._metar_obs[0].temp.value(units='C') + temp_c = self._metar_obs.temp.value(units='C') if temp_c is not None: return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) return None @@ -217,10 +219,10 @@ def temperature(self): @property def pressure(self): """Return the current pressure.""" - pressure_pa = self._observation[0]['seaLevelPressure']['value'] + pressure_pa = self._observation['seaLevelPressure']['value'] if pressure_pa is None and self._metar_obs: - pressure_hpa = self._metar_obs[0].press.value(units='HPA') + pressure_hpa = self._metar_obs.press.value(units='HPA') if pressure_hpa is None: return None pressure_pa = convert_pressure(pressure_hpa, PRESSURE_HPA, @@ -238,14 +240,14 @@ def pressure(self): @property def humidity(self): """Return the name of the sensor.""" - return self._observation[0]['relativeHumidity']['value'] + return self._observation['relativeHumidity']['value'] @property def wind_speed(self): """Return the current windspeed.""" - wind_m_s = self._observation[0]['windSpeed']['value'] + wind_m_s = self._observation['windSpeed']['value'] if wind_m_s is None and self._metar_obs: - wind_m_s = self._metar_obs[0].wind_speed.value(units='MPS') + wind_m_s = self._metar_obs.wind_speed.value(units='MPS') print(wind_m_s) if wind_m_s is None: return None @@ -261,9 +263,9 @@ def wind_speed(self): @property def wind_bearing(self): """Return the current wind bearing (degrees).""" - wind_bearing = self._observation[0]['windDirection']['value'] + wind_bearing = self._observation['windDirection']['value'] if wind_bearing is None and self._metar_obs: - wind_bearing = self._metar_obs[0].wind_dir.value() + wind_bearing = self._metar_obs.wind_dir.value() return wind_bearing @property @@ -274,16 +276,16 @@ def temperature_unit(self): @property def condition(self): """Return current condition.""" - time, weather = parse_icon(self._observation[0]['icon']) + time, weather = parse_icon(self._observation['icon']) cond, _ = convert_condition(time, weather) return cond @property def visibility(self): """Return visibility.""" - vis_m = self._observation[0]['visibility']['value'] + vis_m = self._observation['visibility']['value'] if vis_m is None and self._metar_obs: - vis_m = self._metar_obs[0].vis.value(units='M') + vis_m = self._metar_obs.vis.value(units='M') if vis_m is None: return None diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index 8cd73e777fe58a..3ba9cb90468ba5 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -95,7 +95,7 @@ def __init__(self, websession, latlon, userid): """Init mock nws.""" pass - async def observations(self): + async def observations(self, limit): """Mock Observation.""" return OBS @@ -330,7 +330,7 @@ def __init__(self, websession, latlon, userid): """Init mock nws.""" pass - async def observations(self): + async def observations(self, limit): """Mock Observation.""" return OBS_METAR From 4d90b20bceed77995e783d1802eee517aa1c4edd Mon Sep 17 00:00:00 2001 From: ktdad Date: Mon, 20 May 2019 07:22:53 -0400 Subject: [PATCH 07/36] add mocked metar for tests --- tests/components/nws/test_nws.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index 3ba9cb90468ba5..0f437abc1d07f3 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -112,6 +112,22 @@ async def stations(self): return [STN] +class Prop: + """Property data class for metar. Initialize with desired return value.""" + def __init__(self, value_return): + self.value_return = value_return + def value(self, units=''): + return self.value_return + +class MockMetar: + """Mock Metar parser""" + def __init__(self, code): + self.temp = Prop(27) + self.press = Prop(1111) + self.wind_speed = Prop(27) + self.wind_dir = Prop(175) + self.vis = Prop(5000) + class TestNWS(unittest.TestCase): """Test the NWS weather component.""" @@ -348,7 +364,7 @@ async def stations(self): class TestNWS_Metar(unittest.TestCase): - """Test the NWS weather component.""" + """Test the NWS weather component with metar code.""" def setUp(self): """Set up things to be run when tests are started.""" @@ -363,6 +379,7 @@ def tearDown(self): @MockDependency("pynws") @patch("pynws.Nws", new=MockNws_Metar) + @patch("metar.Metar.Metar", new=MockMetar) def test_metar(self, mock_pynws): """Test for successfully setting up the NWS platform with name.""" assert setup_component(self.hass, weather.DOMAIN, { @@ -384,11 +401,14 @@ def test_metar(self, mock_pynws): display_temp(self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE) assert data.get(ATTR_WEATHER_HUMIDITY) is None assert data.get(ATTR_WEATHER_PRESSURE) == round( - convert_pressure(truth.press.value(units='HPA'), PRESSURE_HPA, - PRESSURE_INHG), + convert_pressure(truth.press.value(), PRESSURE_HPA, PRESSURE_INHG), 2) + + wind_speed_mi_s = convert_distance(truth.wind_speed.value(), LENGTH_METERS, + LENGTH_MILES) assert data.get(ATTR_WEATHER_WIND_SPEED) == round( - truth.wind_speed.value(units='MPH')) + wind_speed_mi_s * 3600) assert data.get(ATTR_WEATHER_WIND_BEARING) == truth.wind_dir.value() - assert data.get(ATTR_WEATHER_VISIBILITY) == round( - truth.vis.value(units='MI')) + vis = convert_distance(truth.vis.value(), LENGTH_METERS, LENGTH_MILES) + assert data.get(ATTR_WEATHER_VISIBILITY) == round(vis) + From c69e23d8c8501c2762382e9db68420b956f92fa2 Mon Sep 17 00:00:00 2001 From: ktdad Date: Mon, 20 May 2019 07:56:22 -0400 Subject: [PATCH 08/36] lint --- tests/components/nws/test_nws.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index 0f437abc1d07f3..faa7bae5f94c94 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -114,20 +114,28 @@ async def stations(self): class Prop: """Property data class for metar. Initialize with desired return value.""" + def __init__(self, value_return): + """Initialize with desired return.""" self.value_return = value_return + def value(self, units=''): + """Return provided value.""" return self.value_return - + + class MockMetar: - """Mock Metar parser""" + """Mock Metar parser.""" + def __init__(self, code): + """Set up mocked return values.""" self.temp = Prop(27) self.press = Prop(1111) self.wind_speed = Prop(27) self.wind_dir = Prop(175) self.vis = Prop(5000) + class TestNWS(unittest.TestCase): """Test the NWS weather component.""" @@ -404,11 +412,10 @@ def test_metar(self, mock_pynws): convert_pressure(truth.press.value(), PRESSURE_HPA, PRESSURE_INHG), 2) - wind_speed_mi_s = convert_distance(truth.wind_speed.value(), LENGTH_METERS, - LENGTH_MILES) + wind_speed_mi_s = convert_distance( + truth.wind_speed.value(), LENGTH_METERS, LENGTH_MILES) assert data.get(ATTR_WEATHER_WIND_SPEED) == round( wind_speed_mi_s * 3600) assert data.get(ATTR_WEATHER_WIND_BEARING) == truth.wind_dir.value() vis = convert_distance(truth.vis.value(), LENGTH_METERS, LENGTH_MILES) assert data.get(ATTR_WEATHER_VISIBILITY) == round(vis) - From 5444e1362643e718b6281825e1f04bdb79387b6e Mon Sep 17 00:00:00 2001 From: ktdad Date: Mon, 20 May 2019 19:41:56 -0400 Subject: [PATCH 09/36] mock metar package for all tests --- tests/components/nws/test_nws.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index faa7bae5f94c94..83caadc0c1e20a 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -150,9 +150,10 @@ def tearDown(self): """Stop down everything that was started.""" self.hass.stop() + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test_w_name(self, mock_pynws): + def test_w_name(self, mock_metar, mock_pynws): """Test for successfully setting up the NWS platform with name.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { @@ -187,9 +188,10 @@ def test_w_name(self, mock_pynws): assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == 9 + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test_w_station(self, mock_pynws): + def test_w_station(self, mock_metar, mock_pynws): """Test for successfully setting up the NWS platform with station.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { @@ -201,9 +203,10 @@ def test_w_station(self, mock_pynws): assert self.hass.states.get('weather.stnb') + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test_w_no_name(self, mock_pynws): + def test_w_no_name(self, mock_metar, mock_pynws): """Test for successfully setting up the NWS platform w no name.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { @@ -214,9 +217,10 @@ def test_w_no_name(self, mock_pynws): assert self.hass.states.get('weather.' + STN) + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test__hourly(self, mock_pynws): + def test__hourly(self, mock_metar, mock_pynws): """Test for successfully setting up hourly forecast.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { @@ -239,9 +243,10 @@ def test__hourly(self, mock_pynws): assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 0 assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == 4 + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test_daynight(self, mock_pynws): + def test_daynight(self, mock_metar, mock_pynws): """Test for successfully setting up daynight forecast.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { @@ -252,10 +257,11 @@ def test_daynight(self, mock_pynws): }) assert self.hass.states.get('weather.' + STN) + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test_latlon(self, mock_pynws): - """Test for successfully setting up the NWS platform with lat/lon.""" + def test_latlon(self, mock_metar, mock_pynws): + """Test for successsfully setting up the NWS platform with lat/lon.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { 'platform': 'nws', @@ -266,9 +272,10 @@ def test_latlon(self, mock_pynws): }) assert self.hass.states.get('weather.' + STN) + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test_setup_failure_mode(self, mock_pynws): + def test_setup_failure_mode(self, mock_metar, mock_pynws): """Test for unsuccessfully setting up incorrect mode.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { @@ -279,9 +286,10 @@ def test_setup_failure_mode(self, mock_pynws): }) assert self.hass.states.get('weather.' + STN) is None + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test_setup_failure_no_apikey(self, mock_pynws): + def test_setup_failure_no_apikey(self, mock_metar, mock_pynws): """Test for unsuccessfully setting up without api_key.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { @@ -306,9 +314,10 @@ def tearDown(self): """Stop down everything that was started.""" self.hass.stop() + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws) - def test_metric(self, mock_pynws): + def test_metric(self, mock_metar, mock_pynws): """Test for successfully setting up the NWS platform with name.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { @@ -385,10 +394,11 @@ def tearDown(self): """Stop down everything that was started.""" self.hass.stop() + @MockDependency("metar") @MockDependency("pynws") @patch("pynws.Nws", new=MockNws_Metar) @patch("metar.Metar.Metar", new=MockMetar) - def test_metar(self, mock_pynws): + def test_metar(self, mock_metar, mock_pynws): """Test for successfully setting up the NWS platform with name.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { From e7815a95f6741e477e6f37b4d2b00ed36de4680e Mon Sep 17 00:00:00 2001 From: ktdad Date: Tue, 28 May 2019 07:02:22 -0400 Subject: [PATCH 10/36] add check for metar attributes --- homeassistant/components/nws/weather.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index f19be3694889a6..4bfdcb8927ee4b 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -210,7 +210,7 @@ def name(self): def temperature(self): """Return the current temperature.""" temp_c = self._observation['temperature']['value'] - if temp_c is None and self._metar_obs: + if temp_c is None and self._metar_obs and self._metar_obs.temp: temp_c = self._metar_obs.temp.value(units='C') if temp_c is not None: return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -221,7 +221,7 @@ def pressure(self): """Return the current pressure.""" pressure_pa = self._observation['seaLevelPressure']['value'] - if pressure_pa is None and self._metar_obs: + if pressure_pa is None and self._metar_obs and self._metar_obs.press: pressure_hpa = self._metar_obs.press.value(units='HPA') if pressure_hpa is None: return None @@ -246,9 +246,8 @@ def humidity(self): def wind_speed(self): """Return the current windspeed.""" wind_m_s = self._observation['windSpeed']['value'] - if wind_m_s is None and self._metar_obs: + if wind_m_s is None and self._metar_obs and self._metar_obs.wind_speed: wind_m_s = self._metar_obs.wind_speed.value(units='MPS') - print(wind_m_s) if wind_m_s is None: return None wind_m_hr = wind_m_s * 3600 @@ -264,7 +263,8 @@ def wind_speed(self): def wind_bearing(self): """Return the current wind bearing (degrees).""" wind_bearing = self._observation['windDirection']['value'] - if wind_bearing is None and self._metar_obs: + if wind_bearing is None and (self._metar_obs + and self._metar_obs.wind_dir): wind_bearing = self._metar_obs.wind_dir.value() return wind_bearing @@ -284,7 +284,7 @@ def condition(self): def visibility(self): """Return visibility.""" vis_m = self._observation['visibility']['value'] - if vis_m is None and self._metar_obs: + if vis_m is None and self._metar_obs and self._metar_obs.vis: vis_m = self._metar_obs.vis.value(units='M') if vis_m is None: return None From 20d697dc0408d4b72983c4f511634475017a6a9c Mon Sep 17 00:00:00 2001 From: ktdad Date: Wed, 29 May 2019 19:53:15 -0400 Subject: [PATCH 11/36] catch errors in setup --- homeassistant/components/nws/weather.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 4bfdcb8927ee4b..fb032557ebf6f9 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,9 +1,11 @@ """Support for NWS weather service.""" from collections import OrderedDict from datetime import timedelta +from json import JSONDecodeError import logging from statistics import mean +import aiohttp import async_timeout import voluptuous as vol @@ -15,6 +17,7 @@ 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 @@ -143,8 +146,13 @@ async def async_setup_platform(hass, config, async_add_entities, _LOGGER.debug("Setting up station: %s", station) if station is None: - with async_timeout.timeout(10, loop=hass.loop): - stations = await nws.stations() + try: + with async_timeout.timeout(10, loop=hass.loop): + stations = await nws.stations() + except aiohttp.ClientError, JSONDecodeError as status: + _LOGGER.error("Error getting station list for %s: %s", + nws.latlon, status) + raise PlatformNotReady _LOGGER.debug("Station list: %s", stations) nws.station = stations[0] _LOGGER.debug("Initialized for coordinates %s, %s -> station %s", From 7f71143b9c16bc30a895e9b61bb74434b45df92c Mon Sep 17 00:00:00 2001 From: ktdad Date: Wed, 29 May 2019 19:56:25 -0400 Subject: [PATCH 12/36] add timeout error --- homeassistant/components/nws/weather.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index fb032557ebf6f9..4ea38fbd95dd5c 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,4 +1,5 @@ """Support for NWS weather service.""" +import asyncio from collections import OrderedDict from datetime import timedelta from json import JSONDecodeError @@ -149,7 +150,8 @@ async def async_setup_platform(hass, config, async_add_entities, try: with async_timeout.timeout(10, loop=hass.loop): stations = await nws.stations() - except aiohttp.ClientError, JSONDecodeError as status: + except (aiohttp.ClientError, JSONDecodeError, + asyncio.CancelledError) as status: _LOGGER.error("Error getting station list for %s: %s", nws.latlon, status) raise PlatformNotReady From 63055f949cfe20cdb80b8bbabfd2a8ad1473109b Mon Sep 17 00:00:00 2001 From: ktdad Date: Thu, 30 May 2019 20:50:42 -0400 Subject: [PATCH 13/36] handle request exceptions --- homeassistant/components/nws/weather.py | 34 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 4ea38fbd95dd5c..64745b49628ac3 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -58,6 +58,8 @@ ('partlycloudy', ['few', 'sct']) ]) +ERRORS = (aiohttp.ClientError, JSONDecodeError, asyncio.CancelledError) + FORECAST_CLASSES = { ATTR_FORECAST_DETAIL_DESCRIPTION: 'detailedForecast', ATTR_FORECAST_TEMP: 'temperature', @@ -150,8 +152,7 @@ async def async_setup_platform(hass, config, async_add_entities, try: with async_timeout.timeout(10, loop=hass.loop): stations = await nws.stations() - except (aiohttp.ClientError, JSONDecodeError, - asyncio.CancelledError) as status: + except ERRORS as status: _LOGGER.error("Error getting station list for %s: %s", nws.latlon, status) raise PlatformNotReady @@ -187,24 +188,33 @@ def __init__(self, nws, metar, units, config): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Update Condition.""" - with async_timeout.timeout(10, loop=self.hass.loop): - _LOGGER.debug("Updating station observations %s", - self._nws.station) - - obs = await self._nws.observations(limit=1) + _LOGGER.debug("Updating station observations %s", self._nws.station) + try: + with async_timeout.timeout(10, loop=self.hass.loop): + obs = await self._nws.observations(limit=1) + except ERRORS as status: + _LOGGER.error("Error updating observation from station %s: %s", + self._nws.station, status) + else: self._observation = obs[0] if 'rawMessage' in self._observation.keys(): - self._metar_obs = self._metar(self._observation['rawMessage']) + self._metar_obs = self._metar( + self._observation['rawMessage']) else: self._metar_obs = None - - _LOGGER.debug("Updating forecast") + _LOGGER.debug("Observations: %s", self._observation) + _LOGGER.debug("Updating forecast") + try: if self._mode == 'daynight': self._forecast = await self._nws.forecast() elif self._mode == 'hourly': self._forecast = await self._nws.forecast_hourly() - _LOGGER.debug("Observations: %s", self._observation) - _LOGGER.debug("Forecasts: %s", self._forecast) + except ERRORS as status: + _LOGGER.error("Error updating forecast from station %s: %s", + self._nws.station, status) + else: + _LOGGER.debug("Forecasts: %s", self._forecast) + return @property def attribution(self): From ef687d3d7fd441275284260f3bcb5a65fbc2397b Mon Sep 17 00:00:00 2001 From: ktdad Date: Sat, 1 Jun 2019 07:20:59 -0400 Subject: [PATCH 14/36] check and test for missing observations --- homeassistant/components/nws/weather.py | 57 +++++-- homeassistant/components/weather/__init__.py | 12 +- tests/components/nws/test_nws.py | 167 +++++++++++++++++-- 3 files changed, 201 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 64745b49628ac3..fcdbd53e04f016 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -18,7 +18,7 @@ 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.exceptions import PlatformNotReady, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import config_validation as cv from homeassistant.util import Throttle @@ -139,7 +139,7 @@ async def async_setup_platform(hass, config, async_add_entities, if None in (latitude, longitude): _LOGGER.error("Latitude/longitude not set in Home Assistant config") - return + return ConfigEntryNotReady websession = async_get_clientsession(hass) # ID request as being from HA, pynws prepends the api_key in addition @@ -154,7 +154,7 @@ async def async_setup_platform(hass, config, async_add_entities, stations = await nws.stations() except ERRORS as status: _LOGGER.error("Error getting station list for %s: %s", - nws.latlon, status) + (latitude, longitude), status) raise PlatformNotReady _LOGGER.debug("Station list: %s", stations) nws.station = stations[0] @@ -197,9 +197,9 @@ async def async_update(self): self._nws.station, status) else: self._observation = obs[0] - if 'rawMessage' in self._observation.keys(): - self._metar_obs = self._metar( - self._observation['rawMessage']) + metar_msg = self._observation.get('rawMessage') + if metar_msg: + self._metar_obs = self._metar(metar_msg) else: self._metar_obs = None _LOGGER.debug("Observations: %s", self._observation) @@ -229,7 +229,9 @@ def name(self): @property def temperature(self): """Return the current temperature.""" - temp_c = self._observation['temperature']['value'] + temp_c = None + if self._observation: + temp_c = self._observation.get('temperature', {}).get('value') if temp_c is None and self._metar_obs and self._metar_obs.temp: temp_c = self._metar_obs.temp.value(units='C') if temp_c is not None: @@ -239,7 +241,10 @@ def temperature(self): @property def pressure(self): """Return the current pressure.""" - pressure_pa = self._observation['seaLevelPressure']['value'] + pressure_pa = None + if self._observation: + pressure_pa = self._observation.get('seaLevelPressure', + {}).get('value') if pressure_pa is None and self._metar_obs and self._metar_obs.press: pressure_hpa = self._metar_obs.press.value(units='HPA') @@ -247,9 +252,11 @@ def pressure(self): return None pressure_pa = convert_pressure(pressure_hpa, PRESSURE_HPA, PRESSURE_PA) - + if pressure_pa is None: + return None if self._is_metric: - pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA) + pressure = convert_pressure(pressure_pa, + PRESSURE_PA, PRESSURE_HPA) pressure = round(pressure) else: pressure = convert_pressure(pressure_pa, @@ -260,12 +267,18 @@ def pressure(self): @property def humidity(self): """Return the name of the sensor.""" - return self._observation['relativeHumidity']['value'] + humidity = None + if self._observation: + humidity = self._observation.get('relativeHumidity', + {}).get('value') + return humidity @property def wind_speed(self): """Return the current windspeed.""" - wind_m_s = self._observation['windSpeed']['value'] + wind_m_s = None + if self._observation: + wind_m_s = self._observation.get('windSpeed', {}).get('value') if wind_m_s is None and self._metar_obs and self._metar_obs.wind_speed: wind_m_s = self._metar_obs.wind_speed.value(units='MPS') if wind_m_s is None: @@ -282,7 +295,10 @@ def wind_speed(self): @property def wind_bearing(self): """Return the current wind bearing (degrees).""" - wind_bearing = self._observation['windDirection']['value'] + wind_bearing = None + if self._observation: + wind_bearing = self._observation.get('windDirection', + {}).get('value') if wind_bearing is None and (self._metar_obs and self._metar_obs.wind_dir): wind_bearing = self._metar_obs.wind_dir.value() @@ -296,14 +312,21 @@ def temperature_unit(self): @property def condition(self): """Return current condition.""" - time, weather = parse_icon(self._observation['icon']) - cond, _ = convert_condition(time, weather) - return cond + icon = None + if self._observation: + icon = self._observation.get('icon') + if icon: + time, weather = parse_icon(self._observation['icon']) + cond, _ = convert_condition(time, weather) + return cond + return @property def visibility(self): """Return visibility.""" - vis_m = self._observation['visibility']['value'] + vis_m = None + if self._observation: + vis_m = self._observation.get('visibility', {}).get('value') if vis_m is None and self._metar_obs and self._metar_obs.vis: vis_m = self._metar_obs.vis.value(units='M') if vis_m is None: diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 34cd86347f29da..91f64992c84088 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -116,11 +116,13 @@ def precision(self): @property def state_attributes(self): """Return the state attributes.""" - data = { - ATTR_WEATHER_TEMPERATURE: show_temp( - self.hass, self.temperature, self.temperature_unit, - self.precision), - } + 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/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index 83caadc0c1e20a..b2d32f6800196f 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -2,6 +2,8 @@ import unittest from unittest.mock import patch +import aiohttp + from homeassistant.components import weather from homeassistant.components.nws.weather import ATTR_FORECAST_PRECIP_PROB from homeassistant.components.weather import ( @@ -55,6 +57,19 @@ "relativeHumidity": {"value": None, "qualityControl": "qc:Z"}, }] +OBS_NONE = [{ + "rawMessage": None, + "textDescription": None, + "icon": None, + "temperature": {"value": None, "qualityControl": "qc:Z"}, + "windDirection": {"value": None, "qualityControl": "qc:Z"}, + "windSpeed": {"value": None, "qualityControl": "qc:Z"}, + "seaLevelPressure": {"value": None, "qualityControl": "qc:Z"}, + "visibility": {"value": None, "qualityControl": "qc:Z"}, + "relativeHumidity": {"value": None, "qualityControl": "qc:Z"}, +}] + + FORE = [{ 'endTime': '2018-12-21T18:00:00-05:00', 'windSpeed': '8 to 10 mph', @@ -356,7 +371,7 @@ def test_metric(self, mock_metar, mock_pynws): convert_distance(9, LENGTH_MILES, LENGTH_KILOMETERS)) -class MockNws_Metar(): +class MockNws_Metar(MockNws): """Mock Station from pynws.""" def __init__(self, websession, latlon, userid): @@ -367,18 +382,6 @@ async def observations(self, limit): """Mock Observation.""" return OBS_METAR - async def forecast(self): - """Mock Forecast.""" - return FORE - - async def forecast_hourly(self): - """Mock Hourly Forecast.""" - return HOURLY_FORE - - async def stations(self): - """Mock stations.""" - return [STN] - class TestNWS_Metar(unittest.TestCase): """Test the NWS weather component with metar code.""" @@ -429,3 +432,141 @@ def test_metar(self, mock_metar, mock_pynws): assert data.get(ATTR_WEATHER_WIND_BEARING) == truth.wind_dir.value() vis = convert_distance(truth.vis.value(), LENGTH_METERS, LENGTH_MILES) assert data.get(ATTR_WEATHER_VISIBILITY) == round(vis) + + +class MockNwsFailObs(MockNws): + """Mock Station from pynws.""" + + def __init__(self, websession, latlon, userid): + """Init mock nws.""" + pass + + async def observations(self, limit): + """Mock Observation.""" + raise aiohttp.ClientError + + +class MockNwsFailStn(MockNws): + """Mock Station from pynws.""" + + def __init__(self, websession, latlon, userid): + """Init mock nws.""" + pass + + async def stations(self): + """Mock Observation.""" + raise aiohttp.ClientError + + +class MockNwsFailFore(MockNws): + """Mock Station from pynws.""" + + def __init__(self, websession, latlon, userid): + """Init mock nws.""" + pass + + async def forecast(self): + """Mock Observation.""" + raise aiohttp.ClientError + + +class MockNws_NoObs(MockNws): + """Mock Station from pynws.""" + + def __init__(self, websession, latlon, userid): + """Init mock nws.""" + pass + + async def observations(self, limit): + """Mock Observation.""" + return OBS_NONE + + +class TestFailures(unittest.TestCase): + """Test the NWS weather component.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = IMPERIAL_SYSTEM + self.lat = self.hass.config.latitude = 40.00 + self.lon = self.hass.config.longitude = -8.00 + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @MockDependency("metar") + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNwsFailObs) + def test_obs_fail(self, mock_metar, mock_pynws): + """Test for successfully setting up the NWS platform with name.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeWeather', + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + + @MockDependency("metar") + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNwsFailStn) + def test_fail_stn(self, mock_metar, mock_pynws): + """Test for successfully setting up the NWS platform with name.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeWeather', + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + state = self.hass.states.get('weather.homeweather') + assert state is None + + @MockDependency("metar") + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNwsFailFore) + def test_fail_fore(self, mock_metar, mock_pynws): + """Test for successfully setting up the NWS platform with name.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeWeather', + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + + @MockDependency("metar") + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws_NoObs) + def test_no_obs(self, mock_metar, mock_pynws): + """Test for successfully setting up the NWS platform with name.""" + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeWeather', + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + state = self.hass.states.get('weather.homeweather') + assert state.state == 'unknown' + + @MockDependency("metar") + @MockDependency("pynws") + @patch("pynws.Nws", new=MockNws) + def test_no_lat(self, mock_metar, mock_pynws): + """Test for successfully setting up the NWS platform with name.""" + hass = self.hass + hass.config.latitude = None + + assert setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeWeather', + 'platform': 'nws', + 'api_key': 'test_email', + } + }) + + state = self.hass.states.get('weather.homeweather') + assert state is None From b5727c84a72d51e51f58e62a3899bd5dd526755d Mon Sep 17 00:00:00 2001 From: Matthew Flamm Date: Thu, 4 Jul 2019 06:12:27 -0400 Subject: [PATCH 15/36] refactor to new pynws --- homeassistant/components/nws/weather.py | 232 +++++++++--------------- 1 file changed, 89 insertions(+), 143 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index fcdbd53e04f016..a8b21ea6ebd188 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -30,7 +30,7 @@ ATTRIBUTION = 'Data from National Weather Service/NOAA' -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) CONF_STATION = 'station' @@ -42,20 +42,34 @@ # Catalog of NWS icon weather codes listed at: # https://api.weather.gov/icons CONDITION_CLASSES = OrderedDict([ - ('snowy', ['snow', 'snow_sleet', 'sleet', 'blizzard']), - ('snowy-rainy', ['rain_snow', 'rain_sleet', 'fzra', - 'rain_fzra', 'snow_fzra']), + ('snowy', ['Snow', + 'Sleet', + 'Blizzard']), + ('snowy-rainy', ['Rain/snow', + 'Rain/sleet', + 'Freezing rain/snow', + 'Freezing rain', + 'Rain/freezing rain']), ('hail', []), - ('lightning-rainy', ['tsra', 'tsra_sct', 'tsra_hi']), + ('lightning-rainy', ['Thunderstorm (high cloud cover)', + 'Thunderstorm (medium cloud cover)', + 'Thunderstorm (low cloud cover)']), ('lightning', []), ('pouring', []), - ('rainy', ['rain', 'rain_showers', 'rain_showers_hi']), - ('windy-variant', ['wind_bkn', 'wind_ovc']), - ('windy', ['wind_skc', 'wind_few', 'wind_sct']), - ('fog', ['fog']), - ('clear', ['skc']), # sunny and clear-night - ('cloudy', ['bkn', 'ovc']), - ('partlycloudy', ['few', 'sct']) + ('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, asyncio.CancelledError) @@ -68,13 +82,6 @@ FORECAST_MODE = ['daynight', 'hourly'] -WIND_DIRECTIONS = ['N', 'NNE', 'NE', 'ENE', - 'E', 'ESE', 'SE', 'SSE', - 'S', 'SSW', 'SW', 'WSW', - 'W', 'WNW', 'NW', 'NNW'] - -WIND = {name: idx * 360 / 16 for idx, name in enumerate(WIND_DIRECTIONS)} - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_LATITUDE): cv.latitude, @@ -85,34 +92,15 @@ }) -def parse_icon(icon): - """ - Parse icon url to NWS weather codes. - - Example: - https://api.weather.gov/icons/land/day/skc/tsra,40?size=medium - - Example return: - ('day', (('skc', 0), ('tsra', 40),)) - """ - icon_list = icon.split('/') - time = icon_list[5] - weather = [i.split('?')[0] for i in icon_list[6:]] - code = [w.split(',')[0] for w in weather] - chance = [int(w.split(',')[1]) if len(w.split(',')) == 2 else 0 - for w in weather] - return time, tuple(zip(code, chance)) - - 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 fitst condition from NWS + If no match is found, return first condition from NWS """ conditions = [w[0] for w in weather] - prec_prob = [w[1] 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() @@ -123,19 +111,19 @@ def convert_condition(time, weather): if time == 'day': return 'sunny', max(prec_prob) if time == 'night': - return 'clear-night', max(prec_prob) + return 'clear-night', max(prec_prob) return cond, max(prec_prob) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the NWS weather platform.""" - from pynws import Nws - from metar import Metar + from pynws import SimpleNws 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] if None in (latitude, longitude): _LOGGER.error("Latitude/longitude not set in Home Assistant config") @@ -143,77 +131,54 @@ async def async_setup_platform(hass, config, async_add_entities, websession = async_get_clientsession(hass) # ID request as being from HA, pynws prepends the api_key in addition - api_key_ha = '{} {}'.format(api_key, 'homeassistant') - nws = Nws(websession, latlon=(float(latitude), float(longitude)), - userid=api_key_ha) + api_key_ha = f"{api_key} homeassistant" + nws = simple_nws(lat, lon, userid=api_key_ha, mode, websession) _LOGGER.debug("Setting up station: %s", station) - if station is None: - try: - with async_timeout.timeout(10, loop=hass.loop): - stations = await nws.stations() - except ERRORS as status: - _LOGGER.error("Error getting station list for %s: %s", - (latitude, longitude), status) - raise PlatformNotReady - _LOGGER.debug("Station list: %s", stations) - nws.station = stations[0] - _LOGGER.debug("Initialized for coordinates %s, %s -> station %s", - latitude, longitude, stations[0]) - else: - nws.station = station - _LOGGER.debug("Initialized station %s", station[0]) + 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, Metar.Metar, hass.config.units, config)], + [NWSWeather(nws, hass.config.units, config)], True) class NWSWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, nws, metar, units, config): + def __init__(self, nws, units, config): """Initialise the platform with a data instance and station name.""" - self._nws = nws - self._metar = metar - self._station_name = config.get(CONF_NAME, self._nws.station) - - self._metar_obs = None - self._observation = None - self._forecast = None - self._description = None - self._is_metric = units.is_metric - self._mode = config[CONF_MODE] + self.nws = nws + self.station_name = config.get(CONF_NAME, self._nws.station) + self.is_metric = units.is_metric @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Update Condition.""" - _LOGGER.debug("Updating station observations %s", self._nws.station) + _LOGGER.debug("Updating station observations %s", self.nws.station) try: - with async_timeout.timeout(10, loop=self.hass.loop): - obs = await self._nws.observations(limit=1) + await self.nws.update_observations() except ERRORS as status: _LOGGER.error("Error updating observation from station %s: %s", - self._nws.station, status) + self.nws.station, status) else: - self._observation = obs[0] - metar_msg = self._observation.get('rawMessage') - if metar_msg: - self._metar_obs = self._metar(metar_msg) - else: - self._metar_obs = None - _LOGGER.debug("Observations: %s", self._observation) + self.observation = self.nws.observation _LOGGER.debug("Updating forecast") try: - if self._mode == 'daynight': - self._forecast = await self._nws.forecast() - elif self._mode == 'hourly': - self._forecast = await self._nws.forecast_hourly() + await self.nws.update_forecast() except ERRORS as status: _LOGGER.error("Error updating forecast from station %s: %s", - self._nws.station, status) + self.nws.station, status) else: - _LOGGER.debug("Forecasts: %s", self._forecast) + self.forecast = self.nws.forecast return @property @@ -224,17 +189,15 @@ def attribution(self): @property def name(self): """Return the name of the station.""" - return self._station_name + return self.station_name @property def temperature(self): """Return the current temperature.""" temp_c = None - if self._observation: - temp_c = self._observation.get('temperature', {}).get('value') - if temp_c is None and self._metar_obs and self._metar_obs.temp: - temp_c = self._metar_obs.temp.value(units='C') - if temp_c is not None: + if self.observation: + temp_c = self.observation.get('temperature') + if temp_c: return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) return None @@ -243,18 +206,10 @@ def pressure(self): """Return the current pressure.""" pressure_pa = None if self._observation: - pressure_pa = self._observation.get('seaLevelPressure', - {}).get('value') - - if pressure_pa is None and self._metar_obs and self._metar_obs.press: - pressure_hpa = self._metar_obs.press.value(units='HPA') - if pressure_hpa is None: - return None - pressure_pa = convert_pressure(pressure_hpa, PRESSURE_HPA, - PRESSURE_PA) + pressure_pa = self.observation.get('seaLevelPressure') if pressure_pa is None: return None - if self._is_metric: + if self.is_metric: pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA) pressure = round(pressure) @@ -268,19 +223,16 @@ def pressure(self): def humidity(self): """Return the name of the sensor.""" humidity = None - if self._observation: - humidity = self._observation.get('relativeHumidity', - {}).get('value') + 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', {}).get('value') - if wind_m_s is None and self._metar_obs and self._metar_obs.wind_speed: - wind_m_s = self._metar_obs.wind_speed.value(units='MPS') + 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 @@ -296,12 +248,8 @@ def wind_speed(self): def wind_bearing(self): """Return the current wind bearing (degrees).""" wind_bearing = None - if self._observation: - wind_bearing = self._observation.get('windDirection', - {}).get('value') - if wind_bearing is None and (self._metar_obs - and self._metar_obs.wind_dir): - wind_bearing = self._metar_obs.wind_dir.value() + if self.observation: + wind_bearing = self.observation.get('windBearing') return wind_bearing @property @@ -312,23 +260,22 @@ def temperature_unit(self): @property def condition(self): """Return current condition.""" - icon = None - if self._observation: - icon = self._observation.get('icon') - if icon: - time, weather = parse_icon(self._observation['icon']) + 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 + return None @property def visibility(self): """Return visibility.""" vis_m = None - if self._observation: - vis_m = self._observation.get('visibility', {}).get('value') - if vis_m is None and self._metar_obs and self._metar_obs.vis: - vis_m = self._metar_obs.vis.value(units='M') + if self.observation: + vis_m = self._observation.get('visibility') if vis_m is None: return None @@ -343,27 +290,26 @@ def forecast(self): """Return forecast.""" forecast = [] for forecast_entry in self._forecast: - data = {attr: forecast_entry[name] - for attr, name in FORECAST_CLASSES.items()} + 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['isDaytime'] - time, weather = parse_icon(forecast_entry['icon']) + data[ATTR_FORECAST_DAYTIME] = forecast_entry.get('isDaytime') + time = forecast_entry.get('iconTime') + weather = forecast_entry.get('iconWeather') cond, precip = convert_condition(time, weather) data[ATTR_FORECAST_CONDITION] = cond - if precip > 0: - data[ATTR_FORECAST_PRECIP_PROB] = precip - else: - data[ATTR_FORECAST_PRECIP_PROB] = None + data[ATTR_FORECAST_PRECIP_PROB] = precip + data[ATTR_FORECAST_WIND_BEARING] = \ WIND[forecast_entry['windDirection']] - - # wind speed reported as '7 mph' or '7 to 10 mph' - # if range, take average - wind_speed = forecast_entry['windSpeed'].split(' ')[0::2] - wind_speed_avg = mean(int(w) for w in wind_speed) + wind_speed = forecast_entry.get('windSpeedAvg') if self._is_metric: data[ATTR_FORECAST_WIND_SPEED] = round( - convert_distance(wind_speed_avg, + convert_distance(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS)) else: data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed_avg) From fd3a90a91e7150b7fb40157c9b014f2f475debcf Mon Sep 17 00:00:00 2001 From: ktdad Date: Thu, 18 Jul 2019 06:50:03 -0400 Subject: [PATCH 16/36] change to simpler api --- homeassistant/components/nws/manifest.json | 5 +- homeassistant/components/nws/weather.py | 75 +-- requirements_all.txt | 5 +- tests/components/nws/test_nws.py | 509 +++++---------------- 4 files changed, 152 insertions(+), 442 deletions(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 58d735602de2a7..510462437aa41d 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -4,8 +4,5 @@ "documentation": "https://www.home-assistant.io/components/nws", "dependencies": [], "codeowners": ["@MatthewFlamm"], - "requirements": [ - "pynws==0.6", - "metar==1.7.0" - ] + "requirements": ["pynws==0.7.0"] } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index a8b21ea6ebd188..4350c29a4b58f0 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,13 +1,10 @@ """Support for NWS weather service.""" -import asyncio from collections import OrderedDict from datetime import timedelta from json import JSONDecodeError import logging -from statistics import mean import aiohttp -import async_timeout import voluptuous as vol from homeassistant.components.weather import ( @@ -72,13 +69,7 @@ 'Partly cloudy']), ]) -ERRORS = (aiohttp.ClientError, JSONDecodeError, asyncio.CancelledError) - -FORECAST_CLASSES = { - ATTR_FORECAST_DETAIL_DESCRIPTION: 'detailedForecast', - ATTR_FORECAST_TEMP: 'temperature', - ATTR_FORECAST_TIME: 'startTime', -} +ERRORS = (aiohttp.ClientError, JSONDecodeError) FORECAST_MODE = ['daynight', 'hourly'] @@ -88,7 +79,7 @@ vol.Optional(CONF_LONGITUDE): 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 + vol.Required(CONF_API_KEY): cv.string, }) @@ -109,16 +100,16 @@ def convert_condition(time, weather): if cond == 'clear': if time == 'day': - return 'sunny', max(prec_prob) + return 'sunny', max(prec_probs) if time == 'night': - return 'clear-night', max(prec_prob) - return cond, max(prec_prob) + 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.""" - from pynws import SimpleNws + from pynws import SimpleNWS latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) station = config.get(CONF_STATION) @@ -132,7 +123,7 @@ async def async_setup_platform(hass, config, async_add_entities, 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 = simple_nws(lat, lon, userid=api_key_ha, mode, websession) + nws = SimpleNWS(latitude, longitude, api_key_ha, mode, websession) _LOGGER.debug("Setting up station: %s", station) try: @@ -147,25 +138,29 @@ async def async_setup_platform(hass, config, async_add_entities, latitude, longitude, nws.station) async_add_entities( - [NWSWeather(nws, hass.config.units, config)], + [NWSWeather(nws, mode, hass.config.units, config)], True) class NWSWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, nws, units, config): + 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.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_observations() + await self.nws.update_observation() except ERRORS as status: _LOGGER.error("Error updating observation from station %s: %s", self.nws.station, status) @@ -178,7 +173,7 @@ async def async_update(self): _LOGGER.error("Error updating forecast from station %s: %s", self.nws.station, status) else: - self.forecast = self.nws.forecast + self._forecast = self.nws.forecast return @property @@ -205,7 +200,7 @@ def temperature(self): def pressure(self): """Return the current pressure.""" pressure_pa = None - if self._observation: + if self.observation: pressure_pa = self.observation.get('seaLevelPressure') if pressure_pa is None: return None @@ -237,7 +232,7 @@ def wind_speed(self): return None wind_m_hr = wind_m_s * 3600 - if self._is_metric: + if self.is_metric: wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_KILOMETERS) else: @@ -275,11 +270,11 @@ def visibility(self): """Return visibility.""" vis_m = None if self.observation: - vis_m = self._observation.get('visibility') + vis_m = self.observation.get('visibility') if vis_m is None: return None - if self._is_metric: + if self.is_metric: vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_KILOMETERS) else: vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_MILES) @@ -288,31 +283,39 @@ def visibility(self): @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_DETAIL_DESCRIPTION: forecast_entry.get( + 'detailedForecast'), ATTR_FORECAST_TEMP: forecast_entry.get('temperature'), ATTR_FORECAST_TIME: forecast_entry.get('startTime'), } - - if self._mode == 'daynight': + + if self.mode == 'daynight': data[ATTR_FORECAST_DAYTIME] = forecast_entry.get('isDaytime') time = forecast_entry.get('iconTime') weather = forecast_entry.get('iconWeather') - cond, precip = convert_condition(time, weather) + 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] = \ - WIND[forecast_entry['windDirection']] + forecast_entry.get('windBearing') wind_speed = forecast_entry.get('windSpeedAvg') - if self._is_metric: - data[ATTR_FORECAST_WIND_SPEED] = round( - convert_distance(wind_speed, - LENGTH_MILES, LENGTH_KILOMETERS)) + 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] = round(wind_speed_avg) - + data[ATTR_FORECAST_WIND_SPEED] = None forecast.append(data) return forecast diff --git a/requirements_all.txt b/requirements_all.txt index e89f26bb1b745e..2646eebacd07ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -765,9 +765,6 @@ mbddns==0.1.2 # homeassistant.components.message_bird messagebird==1.2.0 -# homeassistant.components.nws -metar==1.7.0 - # homeassistant.components.meteoalarm meteoalertapi==0.1.5 @@ -1292,7 +1289,7 @@ pynuki==1.3.3 pynut2==2.1.2 # homeassistant.components.nws -pynws==0.6 +pynws==0.7.0 # homeassistant.components.nx584 pynx584==0.4 diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index b2d32f6800196f..211432380d136b 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -26,129 +26,74 @@ from tests.common import get_test_home_assistant, MockDependency - -OBS = [{ - 'temperature': {'value': 7, 'qualityControl': 'qc:V'}, - 'relativeHumidity': {'value': 10, 'qualityControl': 'qc:V'}, - 'windChill': {'value': 10, 'qualityControl': 'qc:V'}, - 'heatIndex': {'value': 10, 'qualityControl': 'qc:V'}, - 'windDirection': {'value': 180, 'qualityControl': 'qc:V'}, - 'visibility': {'value': 10000, 'qualityControl': 'qc:V'}, - 'windSpeed': {'value': 10, 'qualityControl': 'qc:V'}, - 'seaLevelPressure': {'value': 30000, 'qualityControl': 'qc:V'}, - 'windGust': {'value': 10, 'qualityControl': 'qc:V'}, - 'dewpoint': {'value': 10, 'qualityControl': 'qc:V'}, - 'icon': 'https://api.weather.gov/icons/land/day/skc?size=medium', - 'textDescription': 'Sunny' -}] - -METAR_MSG = ("PHNG 182257Z 06012KT 10SM FEW020 SCT026 SCT035 " - "28/22 A3007 RMK AO2 SLP177 T02780217") - -OBS_METAR = [{ - "rawMessage": METAR_MSG, - "textDescription": "Partly Cloudy", - "icon": "https://api.weather.gov/icons/land/day/sct?size=medium", - "temperature": {"value": None, "qualityControl": "qc:Z"}, - "windDirection": {"value": None, "qualityControl": "qc:Z"}, - "windSpeed": {"value": None, "qualityControl": "qc:Z"}, - "seaLevelPressure": {"value": None, "qualityControl": "qc:Z"}, - "visibility": {"value": None, "qualityControl": "qc:Z"}, - "relativeHumidity": {"value": None, "qualityControl": "qc:Z"}, -}] - -OBS_NONE = [{ - "rawMessage": None, - "textDescription": None, - "icon": None, - "temperature": {"value": None, "qualityControl": "qc:Z"}, - "windDirection": {"value": None, "qualityControl": "qc:Z"}, - "windSpeed": {"value": None, "qualityControl": "qc:Z"}, - "seaLevelPressure": {"value": None, "qualityControl": "qc:Z"}, - "visibility": {"value": None, "qualityControl": "qc:Z"}, - "relativeHumidity": {"value": None, "qualityControl": "qc:Z"}, -}] - +OBS = { + 'temperature': 7, + 'relativeHumidity': 10, + 'windBearing': 180, + 'visibility': 10000, + 'windSpeed': 10, + 'seaLevelPressure': 30000, + 'iconTime': 'day', + 'iconWeather': (('Fair/clear', None),), + } FORE = [{ - 'endTime': '2018-12-21T18:00:00-05:00', - 'windSpeed': '8 to 10 mph', - 'windDirection': 'S', - 'shortForecast': 'Chance Showers And Thunderstorms', - 'isDaytime': True, - 'startTime': '2018-12-21T15:00:00-05:00', - 'temperatureTrend': None, 'temperature': 41, - 'temperatureUnit': 'F', - 'detailedForecast': 'A detailed description', - 'name': 'This Afternoon', - 'number': 1, - 'icon': 'https://api.weather.gov/icons/land/day/skc/tsra,40?size=medium' -}] - -HOURLY_FORE = [{ - 'endTime': '2018-12-22T05:00:00-05:00', - 'windSpeed': '4 mph', - 'windDirection': 'N', - 'shortForecast': 'Chance Showers And Thunderstorms', - 'startTime': '2018-12-22T04:00:00-05:00', - 'temperatureTrend': None, - 'temperature': 32, - 'temperatureUnit': 'F', - 'detailedForecast': '', - 'number': 2, - 'icon': 'https://api.weather.gov/icons/land/night/skc?size=medium' + 'windBearing': 180, + 'windSpeedAvg': 9, + 'iconTime': 'day', + 'startTime': '2018-12-21T15:00:00-05:00', + 'iconWeather': (('Fair/Clear', None), + ('Thunderstorm (high cloud cover)', 40),), }] STN = 'STNA' -class MockNws(): +class MockNws: """Mock Station from pynws.""" - def __init__(self, websession, latlon, userid): - """Init mock nws.""" - pass + data_obs = None + data_fore = None - async def observations(self, limit): + error_obs = False + error_fore = False + error_stn = False + + def __init__(self, lat, lon, userid, mode, session): + """Init mock nws.""" + self.station = None + self.stations = None + + async def update_observation(self): + """Mock observation update.""" + if self.error_obs: + raise aiohttp.ClientError + return + + async def update_forecast(self): + """Mock forecast update.""" + if self.error_fore: + raise aiohttp.ClientError + return + + @property + def observation(self): """Mock Observation.""" - return OBS + return self.data_obs - async def forecast(self): + @property + def forecast(self): """Mock Forecast.""" - return FORE - - async def forecast_hourly(self): - """Mock Hourly Forecast.""" - return HOURLY_FORE + return self.data_fore - async def stations(self): + async def set_station(self, station=None): """Mock stations.""" - return [STN] - - -class Prop: - """Property data class for metar. Initialize with desired return value.""" - - def __init__(self, value_return): - """Initialize with desired return.""" - self.value_return = value_return - - def value(self, units=''): - """Return provided value.""" - return self.value_return - - -class MockMetar: - """Mock Metar parser.""" - - def __init__(self, code): - """Set up mocked return values.""" - self.temp = Prop(27) - self.press = Prop(1111) - self.wind_speed = Prop(27) - self.wind_dir = Prop(175) - self.vis = Prop(5000) + if self.error_stn: + raise aiohttp.ClientError + self.stations = [STN] + self.station = station or STN + return class TestNWS(unittest.TestCase): @@ -161,15 +106,25 @@ def setUp(self): self.lat = self.hass.config.latitude = 40.00 self.lon = self.hass.config.longitude = -8.00 + # Initialize class variables as tests modify them + MockNws.data_obs = None + MockNws.data_fore = None + + MockNws.error_obs = False + MockNws.error_fore = False + MockNws.error_stn = False + def tearDown(self): """Stop down everything that was started.""" self.hass.stop() - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_w_name(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform with name.""" + @MockDependency('pynws') + @patch("pynws.SimpleNWS", new=MockNws) + def test_nws(self, mock_pynws): + """Test for successfully setting up with imperial.""" + mock_pynws.SimpleNWS.data_obs = OBS + mock_pynws.SimpleNWS.data_fore = FORE + assert setup_component(self.hass, weather.DOMAIN, { 'weather': { 'name': 'HomeWeather', @@ -203,137 +158,14 @@ def test_w_name(self, mock_metar, mock_pynws): assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == 9 - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_w_station(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform with station.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'platform': 'nws', - 'station': 'STNB', - 'api_key': 'test_email', - } - }) - - assert self.hass.states.get('weather.stnb') + @MockDependency('pynws') + @patch("pynws.SimpleNWS", new=MockNws) + def test_nws_metric(self, mock_pynws): + """Test for successfully setting up with metric.""" + mock_pynws.SimpleNWS.data_obs = OBS + mock_pynws.SimpleNWS.data_fore = FORE - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_w_no_name(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform w no name.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'platform': 'nws', - 'api_key': 'test_email', - } - }) - - assert self.hass.states.get('weather.' + STN) - - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test__hourly(self, mock_metar, mock_pynws): - """Test for successfully setting up hourly forecast.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HourlyWeather', - 'platform': 'nws', - 'api_key': 'test_email', - 'mode': 'hourly', - } - }) - - state = self.hass.states.get('weather.hourlyweather') - data = state.attributes - - forecast = data.get(ATTR_FORECAST) - assert forecast[0].get(ATTR_FORECAST_CONDITION) == 'clear-night' - assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) is None - assert forecast[0].get(ATTR_FORECAST_TEMP) == 32 - assert forecast[0].get(ATTR_FORECAST_TIME) == \ - '2018-12-22T04:00:00-05:00' - assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 0 - assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == 4 - - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_daynight(self, mock_metar, mock_pynws): - """Test for successfully setting up daynight forecast.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'platform': 'nws', - 'api_key': 'test_email', - 'mode': 'daynight', - } - }) - assert self.hass.states.get('weather.' + STN) - - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_latlon(self, mock_metar, mock_pynws): - """Test for successsfully setting up the NWS platform with lat/lon.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'platform': 'nws', - 'api_key': 'test_email', - 'latitude': self.lat, - 'longitude': self.lon, - } - }) - assert self.hass.states.get('weather.' + STN) - - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_setup_failure_mode(self, mock_metar, mock_pynws): - """Test for unsuccessfully setting up incorrect mode.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'platform': 'nws', - 'api_key': 'test_email', - 'mode': 'abc', - } - }) - assert self.hass.states.get('weather.' + STN) is None - - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_setup_failure_no_apikey(self, mock_metar, mock_pynws): - """Test for unsuccessfully setting up without api_key.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'platform': 'nws', - } - }) - - assert self.hass.states.get('weather.' + STN) is None - - -class TestNwsMetric(unittest.TestCase): - """Test the NWS weather component using metric units.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() self.hass.config.units = METRIC_SYSTEM - self.lat = self.hass.config.latitude = 40.00 - self.lon = self.hass.config.longitude = -8.00 - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_metric(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform with name.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { 'name': 'HomeWeather', @@ -346,14 +178,13 @@ def test_metric(self, mock_metar, mock_pynws): assert state.state == 'sunny' data = state.attributes + temp_f = convert_temperature(7, TEMP_CELSIUS, TEMP_FAHRENHEIT) assert data.get(ATTR_WEATHER_TEMPERATURE) == \ - display_temp(self.hass, 7, TEMP_CELSIUS, PRECISION_WHOLE) - + display_temp(self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE) assert data.get(ATTR_WEATHER_HUMIDITY) == 10 assert data.get(ATTR_WEATHER_PRESSURE) == round( convert_pressure(30000, PRESSURE_PA, PRESSURE_HPA)) - # m/s to km/hr - assert data.get(ATTR_WEATHER_WIND_SPEED) == round(10 * 3.6) + assert data.get(ATTR_WEATHER_WIND_SPEED) == round(3.6 * 10) assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 assert data.get(ATTR_WEATHER_VISIBILITY) == round( convert_distance(10000, LENGTH_METERS, LENGTH_KILOMETERS)) @@ -362,47 +193,18 @@ def test_metric(self, mock_metar, mock_pynws): forecast = data.get(ATTR_FORECAST) assert forecast[0].get(ATTR_FORECAST_CONDITION) == 'lightning-rainy' assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) == 40 - assert forecast[0].get(ATTR_FORECAST_TEMP) == round( - convert_temperature(41, TEMP_FAHRENHEIT, TEMP_CELSIUS)) + assert forecast[0].get(ATTR_FORECAST_TEMP) == convert_temperature( + 41, TEMP_FAHRENHEIT, TEMP_CELSIUS) assert forecast[0].get(ATTR_FORECAST_TIME) == \ '2018-12-21T15:00:00-05:00' assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == round( convert_distance(9, LENGTH_MILES, LENGTH_KILOMETERS)) - -class MockNws_Metar(MockNws): - """Mock Station from pynws.""" - - def __init__(self, websession, latlon, userid): - """Init mock nws.""" - pass - - async def observations(self, limit): - """Mock Observation.""" - return OBS_METAR - - -class TestNWS_Metar(unittest.TestCase): - """Test the NWS weather component with metar code.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.config.units = IMPERIAL_SYSTEM - self.lat = self.hass.config.latitude = 40.00 - self.lon = self.hass.config.longitude = -8.00 - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws_Metar) - @patch("metar.Metar.Metar", new=MockMetar) - def test_metar(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform with name.""" + @MockDependency('pynws') + @patch("pynws.SimpleNWS", new=MockNws) + def test_nws_no_obs_fore1x(self, mock_pynws): + """Test with no data.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { 'name': 'HomeWeather', @@ -411,96 +213,16 @@ def test_metar(self, mock_metar, mock_pynws): } }) - from metar import Metar - truth = Metar.Metar(METAR_MSG) state = self.hass.states.get('weather.homeweather') - data = state.attributes - - temp_f = convert_temperature(truth.temp.value(), TEMP_CELSIUS, - TEMP_FAHRENHEIT) - assert data.get(ATTR_WEATHER_TEMPERATURE) == \ - display_temp(self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE) - assert data.get(ATTR_WEATHER_HUMIDITY) is None - assert data.get(ATTR_WEATHER_PRESSURE) == round( - convert_pressure(truth.press.value(), PRESSURE_HPA, PRESSURE_INHG), - 2) - - wind_speed_mi_s = convert_distance( - truth.wind_speed.value(), LENGTH_METERS, LENGTH_MILES) - assert data.get(ATTR_WEATHER_WIND_SPEED) == round( - wind_speed_mi_s * 3600) - assert data.get(ATTR_WEATHER_WIND_BEARING) == truth.wind_dir.value() - vis = convert_distance(truth.vis.value(), LENGTH_METERS, LENGTH_MILES) - assert data.get(ATTR_WEATHER_VISIBILITY) == round(vis) - - -class MockNwsFailObs(MockNws): - """Mock Station from pynws.""" - - def __init__(self, websession, latlon, userid): - """Init mock nws.""" - pass - - async def observations(self, limit): - """Mock Observation.""" - raise aiohttp.ClientError - - -class MockNwsFailStn(MockNws): - """Mock Station from pynws.""" - - def __init__(self, websession, latlon, userid): - """Init mock nws.""" - pass - - async def stations(self): - """Mock Observation.""" - raise aiohttp.ClientError - - -class MockNwsFailFore(MockNws): - """Mock Station from pynws.""" - - def __init__(self, websession, latlon, userid): - """Init mock nws.""" - pass - - async def forecast(self): - """Mock Observation.""" - raise aiohttp.ClientError - - -class MockNws_NoObs(MockNws): - """Mock Station from pynws.""" - - def __init__(self, websession, latlon, userid): - """Init mock nws.""" - pass - - async def observations(self, limit): - """Mock Observation.""" - return OBS_NONE - - -class TestFailures(unittest.TestCase): - """Test the NWS weather component.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.config.units = IMPERIAL_SYSTEM - self.lat = self.hass.config.latitude = 40.00 - self.lon = self.hass.config.longitude = -8.00 + assert state.state == 'unknown' - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() + @MockDependency('pynws') + @patch("pynws.SimpleNWS", new=MockNws) + def test_nws_missing_valuess(self, mock_pynws): + """Test with missing data.""" + mock_pynws.SimpleNWS.data_obs = {key: None for key in OBS} + mock_pynws.SimpleNWS.data_fore = [{key: None for key in FORE[0]}] - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNwsFailObs) - def test_obs_fail(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform with name.""" assert setup_component(self.hass, weather.DOMAIN, { 'weather': { 'name': 'HomeWeather', @@ -509,26 +231,15 @@ def test_obs_fail(self, mock_metar, mock_pynws): } }) - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNwsFailStn) - def test_fail_stn(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform with name.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeWeather', - 'platform': 'nws', - 'api_key': 'test_email', - } - }) state = self.hass.states.get('weather.homeweather') - assert state is None + assert state.state == 'unknown' - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNwsFailFore) - def test_fail_fore(self, mock_metar, mock_pynws): + @MockDependency('pynws') + @patch("pynws.SimpleNWS", new=MockNws) + def test_nws_error_obs(self, mock_pynws): """Test for successfully setting up the NWS platform with name.""" + mock_pynws.SimpleNWS.error_obs = True + assert setup_component(self.hass, weather.DOMAIN, { 'weather': { 'name': 'HomeWeather', @@ -537,11 +248,14 @@ def test_fail_fore(self, mock_metar, mock_pynws): } }) - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws_NoObs) - def test_no_obs(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform with name.""" + state = self.hass.states.get('weather.homeweather') + assert state.state == 'unknown' + + @MockDependency('pynws') + @patch("pynws.SimpleNWS", new=MockNws) + def test_nws_error_fore(self, mock_pynws): + """Test error forecast.""" + mock_pynws.SimpleNWS.error_fore = True assert setup_component(self.hass, weather.DOMAIN, { 'weather': { 'name': 'HomeWeather', @@ -549,17 +263,16 @@ def test_no_obs(self, mock_metar, mock_pynws): 'api_key': 'test_email', } }) - state = self.hass.states.get('weather.homeweather') - assert state.state == 'unknown' - @MockDependency("metar") - @MockDependency("pynws") - @patch("pynws.Nws", new=MockNws) - def test_no_lat(self, mock_metar, mock_pynws): - """Test for successfully setting up the NWS platform with name.""" - hass = self.hass - hass.config.latitude = None + state = self.hass.states.get('weather.homeweather') + data = state.attributes + assert data.get('forecast') is None + @MockDependency('pynws') + @patch("pynws.SimpleNWS", new=MockNws) + def test_nws_error_stn(self, mock_pynws): + """Test station error..""" + mock_pynws.SimpleNWS.error_stn = True assert setup_component(self.hass, weather.DOMAIN, { 'weather': { 'name': 'HomeWeather', From 4946d91779a6e539ea43e667b2265557a49a0bb5 Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Thu, 18 Jul 2019 09:09:15 -0400 Subject: [PATCH 17/36] Make py3.5 compatible Remove f string --- homeassistant/components/nws/weather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 4350c29a4b58f0..21ec567d073583 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -122,7 +122,7 @@ async def async_setup_platform(hass, config, async_add_entities, 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" + api_key_ha = "{} homeassistant".format(api_key) nws = SimpleNWS(latitude, longitude, api_key_ha, mode, websession) _LOGGER.debug("Setting up station: %s", station) From 3a727aa98026d85a16f11ffce28fbf30e7e3be77 Mon Sep 17 00:00:00 2001 From: ktdad Date: Thu, 18 Jul 2019 14:36:35 -0400 Subject: [PATCH 18/36] bump pynws version --- homeassistant/components/nws/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 510462437aa41d..48a03edb1f5835 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/components/nws", "dependencies": [], "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==0.7.0"] + "requirements": ["pynws==0.7.1"] } From 6f0b414e68f173a1c180d8b851179e996273f12d Mon Sep 17 00:00:00 2001 From: ktdad Date: Thu, 18 Jul 2019 14:43:36 -0400 Subject: [PATCH 19/36] gen_requirements --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 2646eebacd07ce..fb9a2bf5a668f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1289,7 +1289,7 @@ pynuki==1.3.3 pynut2==2.1.2 # homeassistant.components.nws -pynws==0.7.0 +pynws==0.7.1 # homeassistant.components.nx584 pynx584==0.4 From 34190df53b48738d96bdf952d4184ec4d1983346 Mon Sep 17 00:00:00 2001 From: ktdad Date: Sun, 21 Jul 2019 09:14:19 -0400 Subject: [PATCH 20/36] fix wind bearing observation --- homeassistant/components/nws/weather.py | 2 +- tests/components/nws/test_nws.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 21ec567d073583..da0bf6b714fa6c 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -244,7 +244,7 @@ def wind_bearing(self): """Return the current wind bearing (degrees).""" wind_bearing = None if self.observation: - wind_bearing = self.observation.get('windBearing') + wind_bearing = self.observation.get('windDirection') return wind_bearing @property diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index 211432380d136b..b511e48309663b 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -29,7 +29,7 @@ OBS = { 'temperature': 7, 'relativeHumidity': 10, - 'windBearing': 180, + 'windDirection': 180, 'visibility': 10000, 'windSpeed': 10, 'seaLevelPressure': 30000, From 3dc4d8485ff1549c2cd95bd804b2536b5190bab1 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 3 Aug 2019 03:07:27 +0100 Subject: [PATCH 21/36] Revert "Make py3.5 compatible" This reverts commit 4946d91779a6e539ea43e667b2265557a49a0bb5. --- homeassistant/components/nws/weather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index da0bf6b714fa6c..8572aeacf137b2 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -122,7 +122,7 @@ async def async_setup_platform(hass, config, async_add_entities, websession = async_get_clientsession(hass) # ID request as being from HA, pynws prepends the api_key in addition - api_key_ha = "{} homeassistant".format(api_key) + api_key_ha = f"{api_key} homeassistant" nws = SimpleNWS(latitude, longitude, api_key_ha, mode, websession) _LOGGER.debug("Setting up station: %s", station) From 55934905c323c0451933dadc3e0ca63b25fb2ee5 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 3 Aug 2019 11:52:55 +0100 Subject: [PATCH 22/36] Precommit black missed a file? --- homeassistant/components/nws/weather.py | 240 ++++++++++++++---------- 1 file changed, 142 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 8572aeacf137b2..a1b0b129b904e4 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -8,13 +8,29 @@ 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) + 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) + 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, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers import config_validation as cv @@ -25,62 +41,81 @@ _LOGGER = logging.getLogger(__name__) -ATTRIBUTION = 'Data from National Weather Service/NOAA' +ATTRIBUTION = "Data from National Weather Service/NOAA" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -CONF_STATION = 'station' +CONF_STATION = "station" -ATTR_FORECAST_DETAIL_DESCRIPTION = 'detailed_description' -ATTR_FORECAST_PRECIP_PROB = 'precipitation_probability' -ATTR_FORECAST_DAYTIME = 'daytime' +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([ - ('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']), -]) +CONDITION_CLASSES = OrderedDict( + [ + ("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'] +FORECAST_MODE = ["daynight", "hourly"] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): 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, -}) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): 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): @@ -94,22 +129,27 @@ def convert_condition(time, 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) + 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): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the NWS weather platform.""" from pynws import SimpleNWS + latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) station = config.get(CONF_STATION) @@ -129,17 +169,20 @@ async def async_setup_platform(hass, config, async_add_entities, try: await nws.set_station(station) except ERRORS as status: - _LOGGER.error("Error getting station list for %s: %s", - (latitude, longitude), 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) + _LOGGER.debug( + "Initialized for coordinates %s, %s -> station %s", + latitude, + longitude, + nws.station, + ) - async_add_entities( - [NWSWeather(nws, mode, hass.config.units, config)], - True) + async_add_entities([NWSWeather(nws, mode, hass.config.units, config)], True) class NWSWeather(WeatherEntity): @@ -162,16 +205,20 @@ async def async_update(self): try: await self.nws.update_observation() except ERRORS as status: - _LOGGER.error("Error updating observation from station %s: %s", - self.nws.station, 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) + _LOGGER.error( + "Error updating forecast from station %s: %s", self.nws.station, status + ) else: self._forecast = self.nws.forecast return @@ -191,7 +238,7 @@ def temperature(self): """Return the current temperature.""" temp_c = None if self.observation: - temp_c = self.observation.get('temperature') + temp_c = self.observation.get("temperature") if temp_c: return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) return None @@ -201,16 +248,14 @@ def pressure(self): """Return the current pressure.""" pressure_pa = None if self.observation: - pressure_pa = self.observation.get('seaLevelPressure') + 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 = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA) pressure = round(pressure) else: - pressure = convert_pressure(pressure_pa, - PRESSURE_PA, PRESSURE_INHG) + pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_INHG) pressure = round(pressure, 2) return pressure @@ -219,7 +264,7 @@ def humidity(self): """Return the name of the sensor.""" humidity = None if self.observation: - humidity = self.observation.get('relativeHumidity') + humidity = self.observation.get("relativeHumidity") return humidity @property @@ -227,14 +272,13 @@ def wind_speed(self): """Return the current windspeed.""" wind_m_s = None if self.observation: - wind_m_s = self.observation.get('windSpeed') + 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) + wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_KILOMETERS) else: wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_MILES) return round(wind) @@ -244,7 +288,7 @@ def wind_bearing(self): """Return the current wind bearing (degrees).""" wind_bearing = None if self.observation: - wind_bearing = self.observation.get('windDirection') + wind_bearing = self.observation.get("windDirection") return wind_bearing @property @@ -257,8 +301,8 @@ def condition(self): """Return current condition.""" weather = None if self.observation: - weather = self.observation.get('iconWeather') - time = self.observation.get('iconTime') + weather = self.observation.get("iconWeather") + time = self.observation.get("iconTime") if weather: cond, _ = convert_condition(time, weather) @@ -270,7 +314,7 @@ def visibility(self): """Return visibility.""" vis_m = None if self.observation: - vis_m = self.observation.get('visibility') + vis_m = self.observation.get("visibility") if vis_m is None: return None @@ -289,15 +333,16 @@ def forecast(self): 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'), + "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 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: @@ -305,14 +350,13 @@ def forecast(self): 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') + 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)) + convert_distance(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS) + ) else: data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed) else: From 53d7c74839b84639efde9ff4948c80a5095d42dc Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 3 Aug 2019 11:59:44 +0100 Subject: [PATCH 23/36] black test --- tests/components/nws/test_nws.py | 298 ++++++++++++++++++------------- 1 file changed, 175 insertions(+), 123 deletions(-) diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py index b511e48309663b..c2838a4a03cdd4 100644 --- a/tests/components/nws/test_nws.py +++ b/tests/components/nws/test_nws.py @@ -7,16 +7,33 @@ from homeassistant.components import weather 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) + 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) + 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, PRECISION_WHOLE, - PRESSURE_INHG, PRESSURE_PA, PRESSURE_HPA, TEMP_CELSIUS, TEMP_FAHRENHEIT) + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PRECISION_WHOLE, + PRESSURE_INHG, + PRESSURE_PA, + PRESSURE_HPA, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.helpers.temperature import display_temp from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.distance import convert as convert_distance @@ -27,27 +44,28 @@ from tests.common import get_test_home_assistant, MockDependency OBS = { - 'temperature': 7, - 'relativeHumidity': 10, - 'windDirection': 180, - 'visibility': 10000, - 'windSpeed': 10, - 'seaLevelPressure': 30000, - 'iconTime': 'day', - 'iconWeather': (('Fair/clear', None),), + "temperature": 7, + "relativeHumidity": 10, + "windDirection": 180, + "visibility": 10000, + "windSpeed": 10, + "seaLevelPressure": 30000, + "iconTime": "day", + "iconWeather": (("Fair/clear", None),), +} + +FORE = [ + { + "temperature": 41, + "windBearing": 180, + "windSpeedAvg": 9, + "iconTime": "day", + "startTime": "2018-12-21T15:00:00-05:00", + "iconWeather": (("Fair/Clear", None), ("Thunderstorm (high cloud cover)", 40)), } +] -FORE = [{ - 'temperature': 41, - 'windBearing': 180, - 'windSpeedAvg': 9, - 'iconTime': 'day', - 'startTime': '2018-12-21T15:00:00-05:00', - 'iconWeather': (('Fair/Clear', None), - ('Thunderstorm (high cloud cover)', 40),), -}] - -STN = 'STNA' +STN = "STNA" class MockNws: @@ -118,47 +136,53 @@ def tearDown(self): """Stop down everything that was started.""" self.hass.stop() - @MockDependency('pynws') + @MockDependency("pynws") @patch("pynws.SimpleNWS", new=MockNws) def test_nws(self, mock_pynws): """Test for successfully setting up with imperial.""" mock_pynws.SimpleNWS.data_obs = OBS mock_pynws.SimpleNWS.data_fore = FORE - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeWeather', - 'platform': 'nws', - 'api_key': 'test_email', - } - }) - - state = self.hass.states.get('weather.homeweather') - assert state.state == 'sunny' + assert setup_component( + self.hass, + weather.DOMAIN, + { + "weather": { + "name": "HomeWeather", + "platform": "nws", + "api_key": "test_email", + } + }, + ) + + state = self.hass.states.get("weather.homeweather") + assert state.state == "sunny" data = state.attributes temp_f = convert_temperature(7, TEMP_CELSIUS, TEMP_FAHRENHEIT) - assert data.get(ATTR_WEATHER_TEMPERATURE) == \ - display_temp(self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE) + assert data.get(ATTR_WEATHER_TEMPERATURE) == display_temp( + self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE + ) assert data.get(ATTR_WEATHER_HUMIDITY) == 10 assert data.get(ATTR_WEATHER_PRESSURE) == round( - convert_pressure(30000, PRESSURE_PA, PRESSURE_INHG), 2) + convert_pressure(30000, PRESSURE_PA, PRESSURE_INHG), 2 + ) assert data.get(ATTR_WEATHER_WIND_SPEED) == round(10 * 2.237) assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 assert data.get(ATTR_WEATHER_VISIBILITY) == round( - convert_distance(10000, LENGTH_METERS, LENGTH_MILES)) - assert state.attributes.get('friendly_name') == 'HomeWeather' + convert_distance(10000, LENGTH_METERS, LENGTH_MILES) + ) + assert state.attributes.get("friendly_name") == "HomeWeather" forecast = data.get(ATTR_FORECAST) - assert forecast[0].get(ATTR_FORECAST_CONDITION) == 'lightning-rainy' + assert forecast[0].get(ATTR_FORECAST_CONDITION) == "lightning-rainy" assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) == 40 assert forecast[0].get(ATTR_FORECAST_TEMP) == 41 - assert forecast[0].get(ATTR_FORECAST_TIME) == \ - '2018-12-21T15:00:00-05:00' + assert forecast[0].get(ATTR_FORECAST_TIME) == "2018-12-21T15:00:00-05:00" assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == 9 - @MockDependency('pynws') + @MockDependency("pynws") @patch("pynws.SimpleNWS", new=MockNws) def test_nws_metric(self, mock_pynws): """Test for successfully setting up with metric.""" @@ -166,120 +190,148 @@ def test_nws_metric(self, mock_pynws): mock_pynws.SimpleNWS.data_fore = FORE self.hass.config.units = METRIC_SYSTEM - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeWeather', - 'platform': 'nws', - 'api_key': 'test_email', - } - }) - - state = self.hass.states.get('weather.homeweather') - assert state.state == 'sunny' + assert setup_component( + self.hass, + weather.DOMAIN, + { + "weather": { + "name": "HomeWeather", + "platform": "nws", + "api_key": "test_email", + } + }, + ) + + state = self.hass.states.get("weather.homeweather") + assert state.state == "sunny" data = state.attributes temp_f = convert_temperature(7, TEMP_CELSIUS, TEMP_FAHRENHEIT) - assert data.get(ATTR_WEATHER_TEMPERATURE) == \ - display_temp(self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE) + assert data.get(ATTR_WEATHER_TEMPERATURE) == display_temp( + self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE + ) assert data.get(ATTR_WEATHER_HUMIDITY) == 10 assert data.get(ATTR_WEATHER_PRESSURE) == round( - convert_pressure(30000, PRESSURE_PA, PRESSURE_HPA)) + convert_pressure(30000, PRESSURE_PA, PRESSURE_HPA) + ) assert data.get(ATTR_WEATHER_WIND_SPEED) == round(3.6 * 10) assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 assert data.get(ATTR_WEATHER_VISIBILITY) == round( - convert_distance(10000, LENGTH_METERS, LENGTH_KILOMETERS)) - assert state.attributes.get('friendly_name') == 'HomeWeather' + convert_distance(10000, LENGTH_METERS, LENGTH_KILOMETERS) + ) + assert state.attributes.get("friendly_name") == "HomeWeather" forecast = data.get(ATTR_FORECAST) - assert forecast[0].get(ATTR_FORECAST_CONDITION) == 'lightning-rainy' + assert forecast[0].get(ATTR_FORECAST_CONDITION) == "lightning-rainy" assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) == 40 assert forecast[0].get(ATTR_FORECAST_TEMP) == convert_temperature( - 41, TEMP_FAHRENHEIT, TEMP_CELSIUS) - assert forecast[0].get(ATTR_FORECAST_TIME) == \ - '2018-12-21T15:00:00-05:00' + 41, TEMP_FAHRENHEIT, TEMP_CELSIUS + ) + assert forecast[0].get(ATTR_FORECAST_TIME) == "2018-12-21T15:00:00-05:00" assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == round( - convert_distance(9, LENGTH_MILES, LENGTH_KILOMETERS)) + convert_distance(9, LENGTH_MILES, LENGTH_KILOMETERS) + ) - @MockDependency('pynws') + @MockDependency("pynws") @patch("pynws.SimpleNWS", new=MockNws) def test_nws_no_obs_fore1x(self, mock_pynws): """Test with no data.""" - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeWeather', - 'platform': 'nws', - 'api_key': 'test_email', - } - }) - - state = self.hass.states.get('weather.homeweather') - assert state.state == 'unknown' - - @MockDependency('pynws') + assert setup_component( + self.hass, + weather.DOMAIN, + { + "weather": { + "name": "HomeWeather", + "platform": "nws", + "api_key": "test_email", + } + }, + ) + + state = self.hass.states.get("weather.homeweather") + assert state.state == "unknown" + + @MockDependency("pynws") @patch("pynws.SimpleNWS", new=MockNws) def test_nws_missing_valuess(self, mock_pynws): """Test with missing data.""" mock_pynws.SimpleNWS.data_obs = {key: None for key in OBS} mock_pynws.SimpleNWS.data_fore = [{key: None for key in FORE[0]}] - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeWeather', - 'platform': 'nws', - 'api_key': 'test_email', - } - }) - - state = self.hass.states.get('weather.homeweather') - assert state.state == 'unknown' - - @MockDependency('pynws') + assert setup_component( + self.hass, + weather.DOMAIN, + { + "weather": { + "name": "HomeWeather", + "platform": "nws", + "api_key": "test_email", + } + }, + ) + + state = self.hass.states.get("weather.homeweather") + assert state.state == "unknown" + + @MockDependency("pynws") @patch("pynws.SimpleNWS", new=MockNws) def test_nws_error_obs(self, mock_pynws): """Test for successfully setting up the NWS platform with name.""" mock_pynws.SimpleNWS.error_obs = True - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeWeather', - 'platform': 'nws', - 'api_key': 'test_email', - } - }) - - state = self.hass.states.get('weather.homeweather') - assert state.state == 'unknown' - - @MockDependency('pynws') + assert setup_component( + self.hass, + weather.DOMAIN, + { + "weather": { + "name": "HomeWeather", + "platform": "nws", + "api_key": "test_email", + } + }, + ) + + state = self.hass.states.get("weather.homeweather") + assert state.state == "unknown" + + @MockDependency("pynws") @patch("pynws.SimpleNWS", new=MockNws) def test_nws_error_fore(self, mock_pynws): """Test error forecast.""" mock_pynws.SimpleNWS.error_fore = True - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeWeather', - 'platform': 'nws', - 'api_key': 'test_email', - } - }) - - state = self.hass.states.get('weather.homeweather') + assert setup_component( + self.hass, + weather.DOMAIN, + { + "weather": { + "name": "HomeWeather", + "platform": "nws", + "api_key": "test_email", + } + }, + ) + + state = self.hass.states.get("weather.homeweather") data = state.attributes - assert data.get('forecast') is None + assert data.get("forecast") is None - @MockDependency('pynws') + @MockDependency("pynws") @patch("pynws.SimpleNWS", new=MockNws) def test_nws_error_stn(self, mock_pynws): """Test station error..""" mock_pynws.SimpleNWS.error_stn = True - assert setup_component(self.hass, weather.DOMAIN, { - 'weather': { - 'name': 'HomeWeather', - 'platform': 'nws', - 'api_key': 'test_email', - } - }) - - state = self.hass.states.get('weather.homeweather') + assert setup_component( + self.hass, + weather.DOMAIN, + { + "weather": { + "name": "HomeWeather", + "platform": "nws", + "api_key": "test_email", + } + }, + ) + + state = self.hass.states.get("weather.homeweather") assert state is None From 6f6071bf91da140b4bb98b3fb97f823f83185f68 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 3 Aug 2019 12:20:37 +0100 Subject: [PATCH 24/36] add exceptional weather condition --- homeassistant/components/nws/weather.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index a1b0b129b904e4..16d6fb8bc1dfa5 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -56,6 +56,19 @@ # 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", From a37cea4e0d2c9f9cd354c6f2922d336e3b607926 Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Thu, 8 Aug 2019 07:07:14 -0400 Subject: [PATCH 25/36] bump pynws version --- homeassistant/components/nws/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index 48a03edb1f5835..de362df4c35a10 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/components/nws", "dependencies": [], "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==0.7.1"] + "requirements": ["pynws==0.7.2"] } From dbf62157a2bded1f23fc41eefe61b7b64f7abd68 Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Thu, 8 Aug 2019 12:31:23 -0400 Subject: [PATCH 26/36] update requirements_all --- requirements_all.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_all.txt b/requirements_all.txt index 70d7f7184cb607..107a1df627ba1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,7 +1305,7 @@ pynuki==1.3.3 pynut2==2.1.2 # homeassistant.components.nws -pynws==0.7.1 +pynws==0.7.2 # homeassistant.components.nx584 pynx584==0.4 From c94ac01acc4cace9ef84847d27170627dde96b3e Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 10 Aug 2019 01:26:51 +0100 Subject: [PATCH 27/36] address comments --- homeassistant/components/nws/weather.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 16d6fb8bc1dfa5..dee82d8c6cf7c5 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -39,6 +39,8 @@ from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.temperature import convert as convert_temperature +from pynws import SimpleNWS + _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Data from National Weather Service/NOAA" @@ -161,7 +163,6 @@ def convert_condition(time, weather): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the NWS weather platform.""" - from pynws import SimpleNWS latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) @@ -171,7 +172,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= if None in (latitude, longitude): _LOGGER.error("Latitude/longitude not set in Home Assistant config") - return ConfigEntryNotReady + return websession = async_get_clientsession(hass) # ID request as being from HA, pynws prepends the api_key in addition @@ -234,7 +235,6 @@ async def async_update(self): ) else: self._forecast = self.nws.forecast - return @property def attribution(self): From 53b78b32837b55b8a0b61de6192e846f6a486754 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 10 Aug 2019 01:33:43 +0100 Subject: [PATCH 28/36] move observation and forecast outside try-except-else --- homeassistant/components/nws/weather.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index dee82d8c6cf7c5..dcdef4f7b843d3 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -224,8 +224,8 @@ async def async_update(self): self.nws.station, status, ) - else: - self.observation = self.nws.observation + self.observation = self.nws.observation + _LOGGER.debug("Updating forecast") try: await self.nws.update_forecast() @@ -233,8 +233,7 @@ async def async_update(self): _LOGGER.error( "Error updating forecast from station %s: %s", self.nws.station, status ) - else: - self._forecast = self.nws.forecast + self._forecast = self.nws.forecast @property def attribution(self): From 69a3a346a01e037d4290eb919e287c668c88a890 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 10 Aug 2019 01:35:24 +0100 Subject: [PATCH 29/36] Revert "move observation and forecast outside try-except-else" This reverts commit 53b78b32837b55b8a0b61de6192e846f6a486754. --- homeassistant/components/nws/weather.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index dcdef4f7b843d3..dee82d8c6cf7c5 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -224,8 +224,8 @@ async def async_update(self): self.nws.station, status, ) - self.observation = self.nws.observation - + else: + self.observation = self.nws.observation _LOGGER.debug("Updating forecast") try: await self.nws.update_forecast() @@ -233,7 +233,8 @@ async def async_update(self): _LOGGER.error( "Error updating forecast from station %s: %s", self.nws.station, status ) - self._forecast = self.nws.forecast + else: + self._forecast = self.nws.forecast @property def attribution(self): From 4dab74802f67f1d7a19548889e294495353ab8a1 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 10 Aug 2019 01:37:14 +0100 Subject: [PATCH 30/36] remove else from update forecast block --- homeassistant/components/nws/weather.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index dee82d8c6cf7c5..cf569d12b2aaa2 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -233,8 +233,8 @@ async def async_update(self): _LOGGER.error( "Error updating forecast from station %s: %s", self.nws.station, status ) - else: - self._forecast = self.nws.forecast + return + self._forecast = self.nws.forecast @property def attribution(self): From 2dfcaaa1168365ffeb5a867c32a62d63203166fe Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 10 Aug 2019 01:57:45 +0100 Subject: [PATCH 31/36] remove unneeded ConfigEntryNotReady import --- homeassistant/components/nws/weather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index cf569d12b2aaa2..54d1bc0a7c677e 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -31,7 +31,7 @@ TEMP_CELSIUS, TEMP_FAHRENHEIT, ) -from homeassistant.exceptions import PlatformNotReady, ConfigEntryNotReady +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 b97f6fe15b9ef7d3450691d062d23ac04688283d Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Sat, 10 Aug 2019 16:41:16 +0100 Subject: [PATCH 32/36] add scan_interval, reduce min_time_between_updates --- homeassistant/components/nws/weather.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 54d1bc0a7c677e..e9636d7d3f1012 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -45,7 +45,8 @@ ATTRIBUTION = "Data from National Weather Service/NOAA" -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +SCAN_INTERVAL = timedelta(minutes=15) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) CONF_STATION = "station" From f4241b313d4537db967b8db8fde5851004067e55 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Wed, 14 Aug 2019 12:36:06 +0100 Subject: [PATCH 33/36] pytest tests --- homeassistant/components/nws/manifest.json | 2 +- homeassistant/components/nws/weather.py | 13 +- requirements_all.txt | 2 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/nws/test_nws.py | 337 ------- tests/components/nws/test_weather.py | 282 ++++++ 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 +++++++++++++++++++++ 12 files changed, 1773 insertions(+), 345 deletions(-) delete mode 100644 tests/components/nws/test_nws.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/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index de362df4c35a10..b0e5fdb208844a 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/components/nws", "dependencies": [], "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==0.7.2"] + "requirements": ["pynws==0.7.4"] } diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index e9636d7d3f1012..102a5ced5e4e2f 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -5,6 +5,7 @@ import logging import aiohttp +from pynws import SimpleNWS import voluptuous as vol from homeassistant.components.weather import ( @@ -39,8 +40,6 @@ from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.temperature import convert as convert_temperature -from pynws import SimpleNWS - _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Data from National Weather Service/NOAA" @@ -165,16 +164,18 @@ def convert_condition(time, weather): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the NWS weather platform.""" + if (config.get(CONF_LATITUDE) and not config.get(CONF_LONGITUDE)) or ( + not config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE) + ): + _LOGGER.error("Latitude/longitude not set in Home Assistant config") + return + 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] - if None in (latitude, longitude): - _LOGGER.error("Latitude/longitude not set in Home Assistant config") - return - 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" diff --git a/requirements_all.txt b/requirements_all.txt index 107a1df627ba1d..6869fefd57c596 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1305,7 +1305,7 @@ pynuki==1.3.3 pynut2==2.1.2 # homeassistant.components.nws -pynws==0.7.2 +pynws==0.7.4 # homeassistant.components.nx584 pynx584==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6549e9964cacc4..fffd3681e75d3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -289,6 +289,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 edf74b93793442..23dca354331b77 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -117,6 +117,7 @@ "pylitejet", "pymfy", "pymonoprice", + "pynws", "pynx584", "pyopenuv", "pyotp", diff --git a/tests/components/nws/test_nws.py b/tests/components/nws/test_nws.py deleted file mode 100644 index c2838a4a03cdd4..00000000000000 --- a/tests/components/nws/test_nws.py +++ /dev/null @@ -1,337 +0,0 @@ -"""Tests for the NWS weather component.""" -import unittest -from unittest.mock import patch - -import aiohttp - -from homeassistant.components import weather -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, - PRECISION_WHOLE, - PRESSURE_INHG, - PRESSURE_PA, - PRESSURE_HPA, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.helpers.temperature import display_temp -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 setup_component - -from tests.common import get_test_home_assistant, MockDependency - -OBS = { - "temperature": 7, - "relativeHumidity": 10, - "windDirection": 180, - "visibility": 10000, - "windSpeed": 10, - "seaLevelPressure": 30000, - "iconTime": "day", - "iconWeather": (("Fair/clear", None),), -} - -FORE = [ - { - "temperature": 41, - "windBearing": 180, - "windSpeedAvg": 9, - "iconTime": "day", - "startTime": "2018-12-21T15:00:00-05:00", - "iconWeather": (("Fair/Clear", None), ("Thunderstorm (high cloud cover)", 40)), - } -] - -STN = "STNA" - - -class MockNws: - """Mock Station from pynws.""" - - data_obs = None - data_fore = None - - error_obs = False - error_fore = False - error_stn = False - - def __init__(self, lat, lon, userid, mode, session): - """Init mock nws.""" - self.station = None - self.stations = None - - async def update_observation(self): - """Mock observation update.""" - if self.error_obs: - raise aiohttp.ClientError - return - - async def update_forecast(self): - """Mock forecast update.""" - if self.error_fore: - raise aiohttp.ClientError - return - - @property - def observation(self): - """Mock Observation.""" - return self.data_obs - - @property - def forecast(self): - """Mock Forecast.""" - return self.data_fore - - async def set_station(self, station=None): - """Mock stations.""" - if self.error_stn: - raise aiohttp.ClientError - self.stations = [STN] - self.station = station or STN - return - - -class TestNWS(unittest.TestCase): - """Test the NWS weather component.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.hass.config.units = IMPERIAL_SYSTEM - self.lat = self.hass.config.latitude = 40.00 - self.lon = self.hass.config.longitude = -8.00 - - # Initialize class variables as tests modify them - MockNws.data_obs = None - MockNws.data_fore = None - - MockNws.error_obs = False - MockNws.error_fore = False - MockNws.error_stn = False - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - @MockDependency("pynws") - @patch("pynws.SimpleNWS", new=MockNws) - def test_nws(self, mock_pynws): - """Test for successfully setting up with imperial.""" - mock_pynws.SimpleNWS.data_obs = OBS - mock_pynws.SimpleNWS.data_fore = FORE - - assert setup_component( - self.hass, - weather.DOMAIN, - { - "weather": { - "name": "HomeWeather", - "platform": "nws", - "api_key": "test_email", - } - }, - ) - - state = self.hass.states.get("weather.homeweather") - assert state.state == "sunny" - - data = state.attributes - temp_f = convert_temperature(7, TEMP_CELSIUS, TEMP_FAHRENHEIT) - assert data.get(ATTR_WEATHER_TEMPERATURE) == display_temp( - self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE - ) - assert data.get(ATTR_WEATHER_HUMIDITY) == 10 - assert data.get(ATTR_WEATHER_PRESSURE) == round( - convert_pressure(30000, PRESSURE_PA, PRESSURE_INHG), 2 - ) - assert data.get(ATTR_WEATHER_WIND_SPEED) == round(10 * 2.237) - assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 - assert data.get(ATTR_WEATHER_VISIBILITY) == round( - convert_distance(10000, LENGTH_METERS, LENGTH_MILES) - ) - assert state.attributes.get("friendly_name") == "HomeWeather" - - forecast = data.get(ATTR_FORECAST) - assert forecast[0].get(ATTR_FORECAST_CONDITION) == "lightning-rainy" - assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) == 40 - assert forecast[0].get(ATTR_FORECAST_TEMP) == 41 - assert forecast[0].get(ATTR_FORECAST_TIME) == "2018-12-21T15:00:00-05:00" - assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 - assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == 9 - - @MockDependency("pynws") - @patch("pynws.SimpleNWS", new=MockNws) - def test_nws_metric(self, mock_pynws): - """Test for successfully setting up with metric.""" - mock_pynws.SimpleNWS.data_obs = OBS - mock_pynws.SimpleNWS.data_fore = FORE - - self.hass.config.units = METRIC_SYSTEM - assert setup_component( - self.hass, - weather.DOMAIN, - { - "weather": { - "name": "HomeWeather", - "platform": "nws", - "api_key": "test_email", - } - }, - ) - - state = self.hass.states.get("weather.homeweather") - assert state.state == "sunny" - - data = state.attributes - temp_f = convert_temperature(7, TEMP_CELSIUS, TEMP_FAHRENHEIT) - assert data.get(ATTR_WEATHER_TEMPERATURE) == display_temp( - self.hass, temp_f, TEMP_FAHRENHEIT, PRECISION_WHOLE - ) - assert data.get(ATTR_WEATHER_HUMIDITY) == 10 - assert data.get(ATTR_WEATHER_PRESSURE) == round( - convert_pressure(30000, PRESSURE_PA, PRESSURE_HPA) - ) - assert data.get(ATTR_WEATHER_WIND_SPEED) == round(3.6 * 10) - assert data.get(ATTR_WEATHER_WIND_BEARING) == 180 - assert data.get(ATTR_WEATHER_VISIBILITY) == round( - convert_distance(10000, LENGTH_METERS, LENGTH_KILOMETERS) - ) - assert state.attributes.get("friendly_name") == "HomeWeather" - - forecast = data.get(ATTR_FORECAST) - assert forecast[0].get(ATTR_FORECAST_CONDITION) == "lightning-rainy" - assert forecast[0].get(ATTR_FORECAST_PRECIP_PROB) == 40 - assert forecast[0].get(ATTR_FORECAST_TEMP) == convert_temperature( - 41, TEMP_FAHRENHEIT, TEMP_CELSIUS - ) - assert forecast[0].get(ATTR_FORECAST_TIME) == "2018-12-21T15:00:00-05:00" - assert forecast[0].get(ATTR_FORECAST_WIND_BEARING) == 180 - assert forecast[0].get(ATTR_FORECAST_WIND_SPEED) == round( - convert_distance(9, LENGTH_MILES, LENGTH_KILOMETERS) - ) - - @MockDependency("pynws") - @patch("pynws.SimpleNWS", new=MockNws) - def test_nws_no_obs_fore1x(self, mock_pynws): - """Test with no data.""" - assert setup_component( - self.hass, - weather.DOMAIN, - { - "weather": { - "name": "HomeWeather", - "platform": "nws", - "api_key": "test_email", - } - }, - ) - - state = self.hass.states.get("weather.homeweather") - assert state.state == "unknown" - - @MockDependency("pynws") - @patch("pynws.SimpleNWS", new=MockNws) - def test_nws_missing_valuess(self, mock_pynws): - """Test with missing data.""" - mock_pynws.SimpleNWS.data_obs = {key: None for key in OBS} - mock_pynws.SimpleNWS.data_fore = [{key: None for key in FORE[0]}] - - assert setup_component( - self.hass, - weather.DOMAIN, - { - "weather": { - "name": "HomeWeather", - "platform": "nws", - "api_key": "test_email", - } - }, - ) - - state = self.hass.states.get("weather.homeweather") - assert state.state == "unknown" - - @MockDependency("pynws") - @patch("pynws.SimpleNWS", new=MockNws) - def test_nws_error_obs(self, mock_pynws): - """Test for successfully setting up the NWS platform with name.""" - mock_pynws.SimpleNWS.error_obs = True - - assert setup_component( - self.hass, - weather.DOMAIN, - { - "weather": { - "name": "HomeWeather", - "platform": "nws", - "api_key": "test_email", - } - }, - ) - - state = self.hass.states.get("weather.homeweather") - assert state.state == "unknown" - - @MockDependency("pynws") - @patch("pynws.SimpleNWS", new=MockNws) - def test_nws_error_fore(self, mock_pynws): - """Test error forecast.""" - mock_pynws.SimpleNWS.error_fore = True - assert setup_component( - self.hass, - weather.DOMAIN, - { - "weather": { - "name": "HomeWeather", - "platform": "nws", - "api_key": "test_email", - } - }, - ) - - state = self.hass.states.get("weather.homeweather") - data = state.attributes - assert data.get("forecast") is None - - @MockDependency("pynws") - @patch("pynws.SimpleNWS", new=MockNws) - def test_nws_error_stn(self, mock_pynws): - """Test station error..""" - mock_pynws.SimpleNWS.error_stn = True - assert setup_component( - self.hass, - weather.DOMAIN, - { - "weather": { - "name": "HomeWeather", - "platform": "nws", - "api_key": "test_email", - } - }, - ) - - state = self.hass.states.get("weather.homeweather") - assert state is None diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py new file mode 100644 index 00000000000000..e5208041eddc62 --- /dev/null +++ b/tests/components/nws/test_weather.py @@ -0,0 +1,282 @@ +"""Tests for the NWS weather component.""" +import asyncio + +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" + + +@asyncio.coroutine +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"): + yield from 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 + + +@asyncio.coroutine +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"): + yield from 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 + + +@asyncio.coroutine +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"): + yield from 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 + + +@asyncio.coroutine +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"): + yield from async_setup_component(hass, "weather", MINIMAL_CONFIG) + + state = hass.states.get("weather.kmie") + assert state + + +@asyncio.coroutine +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"): + yield from async_setup_component(hass, "weather", MINIMAL_CONFIG) + + state = hass.states.get("weather.kmie") + assert state is None + + +@asyncio.coroutine +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(1, "weather"): + yield from 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 00000000000000..6085bcdada9b01 --- /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 00000000000000..b3f4f4ccea8c6c --- /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 00000000000000..36ae66283e502f --- /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 00000000000000..a6d307fc9b13f2 --- /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 00000000000000..b4fe086366c8fb --- /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 7ef706a2efa2c35ce056881a3dddde9c9632f693 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Thu, 15 Aug 2019 12:58:17 +0100 Subject: [PATCH 34/36] lint test docstring --- tests/components/nws/test_weather.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index e5208041eddc62..bfe68f18b8570b 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -234,7 +234,7 @@ def test_fail_obs(hass, aioclient_mock): @asyncio.coroutine def test_fail_stn(hass, aioclient_mock): - """Test failing station update""" + """Test failing station update.""" aioclient_mock.get( STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json"), From 912d672f71773658acf6ba0210755f3c78d5459c Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Fri, 16 Aug 2019 01:19:58 +0100 Subject: [PATCH 35/36] use async await --- tests/components/nws/test_weather.py | 32 +++++++++++----------------- 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index bfe68f18b8570b..2041c3e5dd3566 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -1,6 +1,4 @@ """Tests for the NWS weather component.""" -import asyncio - from homeassistant.components.nws.weather import ATTR_FORECAST_PRECIP_PROB from homeassistant.components.weather import ( ATTR_WEATHER_HUMIDITY, @@ -106,8 +104,7 @@ FORCURL = "https://api.weather.gov/points/{},{}/forecast" -@asyncio.coroutine -def test_imperial(hass, aioclient_mock): +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") @@ -124,7 +121,7 @@ def test_imperial(hass, aioclient_mock): hass.config.units = IMPERIAL_SYSTEM with assert_setup_component(1, "weather"): - yield from async_setup_component(hass, "weather", MINIMAL_CONFIG) + await async_setup_component(hass, "weather", MINIMAL_CONFIG) state = hass.states.get("weather.kmie") assert state @@ -139,8 +136,7 @@ def test_imperial(hass, aioclient_mock): assert forecast[0].get(key) == value -@asyncio.coroutine -def test_metric(hass, aioclient_mock): +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") @@ -157,7 +153,7 @@ def test_metric(hass, aioclient_mock): hass.config.units = METRIC_SYSTEM with assert_setup_component(1, "weather"): - yield from async_setup_component(hass, "weather", MINIMAL_CONFIG) + await async_setup_component(hass, "weather", MINIMAL_CONFIG) state = hass.states.get("weather.kmie") assert state @@ -172,8 +168,7 @@ def test_metric(hass, aioclient_mock): assert forecast[0].get(key) == value -@asyncio.coroutine -def test_none(hass, aioclient_mock): +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") @@ -190,7 +185,7 @@ def test_none(hass, aioclient_mock): hass.config.units = IMPERIAL_SYSTEM with assert_setup_component(1, "weather"): - yield from async_setup_component(hass, "weather", MINIMAL_CONFIG) + await async_setup_component(hass, "weather", MINIMAL_CONFIG) state = hass.states.get("weather.kmie") assert state @@ -205,8 +200,7 @@ def test_none(hass, aioclient_mock): assert forecast[0].get(key) is None -@asyncio.coroutine -def test_fail_obs(hass, aioclient_mock): +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") @@ -226,14 +220,13 @@ def test_fail_obs(hass, aioclient_mock): hass.config.units = IMPERIAL_SYSTEM with assert_setup_component(1, "weather"): - yield from async_setup_component(hass, "weather", MINIMAL_CONFIG) + await async_setup_component(hass, "weather", MINIMAL_CONFIG) state = hass.states.get("weather.kmie") assert state -@asyncio.coroutine -def test_fail_stn(hass, aioclient_mock): +async def test_fail_stn(hass, aioclient_mock): """Test failing station update.""" aioclient_mock.get( STAURL.format(40.0, -85.0), @@ -252,14 +245,13 @@ def test_fail_stn(hass, aioclient_mock): hass.config.units = IMPERIAL_SYSTEM with assert_setup_component(1, "weather"): - yield from async_setup_component(hass, "weather", MINIMAL_CONFIG) + await async_setup_component(hass, "weather", MINIMAL_CONFIG) state = hass.states.get("weather.kmie") assert state is None -@asyncio.coroutine -def test_invalid_config(hass, aioclient_mock): +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") @@ -276,7 +268,7 @@ def test_invalid_config(hass, aioclient_mock): hass.config.units = IMPERIAL_SYSTEM with assert_setup_component(1, "weather"): - yield from async_setup_component(hass, "weather", INVALID_CONFIG) + await async_setup_component(hass, "weather", INVALID_CONFIG) state = hass.states.get("weather.kmie") assert state is None From dc767583831a1c519ca9979edb27af3e15d560a2 Mon Sep 17 00:00:00 2001 From: "Flamm, Matthew H" Date: Fri, 16 Aug 2019 02:00:14 +0100 Subject: [PATCH 36/36] lat and lon inclusive in config --- homeassistant/components/nws/weather.py | 14 ++++++-------- tests/components/nws/test_weather.py | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 102a5ced5e4e2f..23cf84411a3788 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -124,8 +124,12 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, + 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, @@ -164,12 +168,6 @@ def convert_condition(time, weather): async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the NWS weather platform.""" - if (config.get(CONF_LATITUDE) and not config.get(CONF_LONGITUDE)) or ( - not config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE) - ): - _LOGGER.error("Latitude/longitude not set in Home Assistant config") - return - latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) station = config.get(CONF_STATION) diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 2041c3e5dd3566..436d25750fc518 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -267,7 +267,7 @@ async def test_invalid_config(hass, aioclient_mock): hass.config.units = IMPERIAL_SYSTEM - with assert_setup_component(1, "weather"): + with assert_setup_component(0, "weather"): await async_setup_component(hass, "weather", INVALID_CONFIG) state = hass.states.get("weather.kmie")