diff --git a/homeassistant/components/nws/__init__.py b/homeassistant/components/nws/__init__.py index 569a8adf83b..9cdf17fa264 100644 --- a/homeassistant/components/nws/__init__.py +++ b/homeassistant/components/nws/__init__.py @@ -28,7 +28,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["weather"] +PLATFORMS = ["sensor", "weather"] DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=10) FAILED_SCAN_INTERVAL = datetime.timedelta(minutes=1) diff --git a/homeassistant/components/nws/const.py b/homeassistant/components/nws/const.py index f055bab0203..f82a70ea4e0 100644 --- a/homeassistant/components/nws/const.py +++ b/homeassistant/components/nws/const.py @@ -1,4 +1,6 @@ """Constants for National Weather Service Integration.""" +from datetime import timedelta + from homeassistant.components.weather import ( ATTR_CONDITION_CLOUDY, ATTR_CONDITION_EXCEPTIONAL, @@ -14,6 +16,21 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + DEGREE, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, + LENGTH_METERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_INHG, + PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, +) DOMAIN = "nws" @@ -23,6 +40,11 @@ ATTRIBUTION = "Data from National Weather Service/NOAA" ATTR_FORECAST_DETAILED_DESCRIPTION = "detailed_description" ATTR_FORECAST_DAYTIME = "daytime" +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_UNIT = "unit" +ATTR_UNIT_CONVERT = "unit_convert" +ATTR_UNIT_CONVERT_METHOD = "unit_convert_method" CONDITION_CLASSES = { ATTR_CONDITION_EXCEPTIONAL: [ @@ -75,3 +97,86 @@ NWS_DATA = "nws data" COORDINATOR_OBSERVATION = "coordinator_observation" COORDINATOR_FORECAST = "coordinator_forecast" COORDINATOR_FORECAST_HOURLY = "coordinator_forecast_hourly" + +OBSERVATION_VALID_TIME = timedelta(minutes=20) +FORECAST_VALID_TIME = timedelta(minutes=45) + +SENSOR_TYPES = { + "dewpoint": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Dew Point", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_UNIT_CONVERT: TEMP_CELSIUS, + }, + "temperature": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Temperature", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_UNIT_CONVERT: TEMP_CELSIUS, + }, + "windChill": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Wind Chill", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_UNIT_CONVERT: TEMP_CELSIUS, + }, + "heatIndex": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_LABEL: "Heat Index", + ATTR_UNIT: TEMP_CELSIUS, + ATTR_UNIT_CONVERT: TEMP_CELSIUS, + }, + "relativeHumidity": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_ICON: None, + ATTR_LABEL: "Relative Humidity", + ATTR_UNIT: PERCENTAGE, + ATTR_UNIT_CONVERT: PERCENTAGE, + }, + "windSpeed": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Speed", + ATTR_UNIT: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_CONVERT: SPEED_MILES_PER_HOUR, + }, + "windGust": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:weather-windy", + ATTR_LABEL: "Wind Gust", + ATTR_UNIT: SPEED_KILOMETERS_PER_HOUR, + ATTR_UNIT_CONVERT: SPEED_MILES_PER_HOUR, + }, + "windDirection": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:compass-rose", + ATTR_LABEL: "Wind Direction", + ATTR_UNIT: DEGREE, + ATTR_UNIT_CONVERT: DEGREE, + }, + "barometricPressure": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_ICON: None, + ATTR_LABEL: "Barometric Pressure", + ATTR_UNIT: PRESSURE_PA, + ATTR_UNIT_CONVERT: PRESSURE_INHG, + }, + "seaLevelPressure": { + ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, + ATTR_ICON: None, + ATTR_LABEL: "Sea Level Pressure", + ATTR_UNIT: PRESSURE_PA, + ATTR_UNIT_CONVERT: PRESSURE_INHG, + }, + "visibility": { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:eye", + ATTR_LABEL: "Visibility", + ATTR_UNIT: LENGTH_METERS, + ATTR_UNIT_CONVERT: LENGTH_MILES, + }, +} diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py new file mode 100644 index 00000000000..bff5cdca589 --- /dev/null +++ b/homeassistant/components/nws/sensor.py @@ -0,0 +1,156 @@ +"""Sensors for National Weather Service (NWS).""" +from homeassistant.components.sensor import SensorEntity +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_DEVICE_CLASS, + CONF_LATITUDE, + CONF_LONGITUDE, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, + PERCENTAGE, + PRESSURE_INHG, + PRESSURE_PA, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, +) +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.distance import convert as convert_distance +from homeassistant.util.dt import utcnow +from homeassistant.util.pressure import convert as convert_pressure + +from . import base_unique_id +from .const import ( + ATTR_ICON, + ATTR_LABEL, + ATTR_UNIT, + ATTR_UNIT_CONVERT, + ATTRIBUTION, + CONF_STATION, + COORDINATOR_OBSERVATION, + DOMAIN, + NWS_DATA, + OBSERVATION_VALID_TIME, + SENSOR_TYPES, +) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the NWS weather platform.""" + hass_data = hass.data[DOMAIN][entry.entry_id] + station = entry.data[CONF_STATION] + + entities = [] + for sensor_type, sensor_data in SENSOR_TYPES.items(): + if hass.config.units.is_metric: + unit = sensor_data[ATTR_UNIT] + else: + unit = sensor_data[ATTR_UNIT_CONVERT] + entities.append( + NWSSensor( + entry.data, + hass_data, + sensor_type, + station, + sensor_data[ATTR_LABEL], + sensor_data[ATTR_ICON], + sensor_data[ATTR_DEVICE_CLASS], + unit, + ), + ) + + async_add_entities(entities, False) + + +class NWSSensor(CoordinatorEntity, SensorEntity): + """An NWS Sensor Entity.""" + + def __init__( + self, + entry_data, + hass_data, + sensor_type, + station, + label, + icon, + device_class, + unit, + ): + """Initialise the platform with a data instance.""" + super().__init__(hass_data[COORDINATOR_OBSERVATION]) + self._nws = hass_data[NWS_DATA] + self._latitude = entry_data[CONF_LATITUDE] + self._longitude = entry_data[CONF_LONGITUDE] + self._type = sensor_type + self._station = station + self._label = label + self._icon = icon + self._device_class = device_class + self._unit = unit + + @property + def state(self): + """Return the state.""" + value = self._nws.observation.get(self._type) + if value is None: + return None + if self._unit == SPEED_MILES_PER_HOUR: + return round(convert_distance(value, LENGTH_KILOMETERS, LENGTH_MILES)) + if self._unit == LENGTH_MILES: + return round(convert_distance(value, LENGTH_METERS, LENGTH_MILES)) + if self._unit == PRESSURE_INHG: + return round(convert_pressure(value, PRESSURE_PA, PRESSURE_INHG), 2) + if self._unit == TEMP_CELSIUS: + return round(value, 1) + if self._unit == PERCENTAGE: + return round(value) + return value + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def device_state_attributes(self): + """Return the attribution.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def name(self): + """Return the name of the station.""" + return f"{self._station} {self._label}" + + @property + def unique_id(self): + """Return a unique_id for this entity.""" + return f"{base_unique_id(self._latitude, self._longitude)}_{self._type}" + + @property + def available(self): + """Return if state is available.""" + if self.coordinator.last_update_success_time: + last_success_time = ( + utcnow() - self.coordinator.last_update_success_time + < OBSERVATION_VALID_TIME + ) + else: + last_success_time = False + return self.coordinator.last_update_success or last_success_time + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return False diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 9f4e69bdb8c..c84d1b78ea2 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -1,6 +1,4 @@ """Support for NWS weather service.""" -from datetime import timedelta - from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, @@ -42,15 +40,14 @@ from .const import ( COORDINATOR_OBSERVATION, DAYNIGHT, DOMAIN, + FORECAST_VALID_TIME, HOURLY, NWS_DATA, + OBSERVATION_VALID_TIME, ) PARALLEL_UPDATES = 0 -OBSERVATION_VALID_TIME = timedelta(minutes=20) -FORECAST_VALID_TIME = timedelta(minutes=45) - def convert_condition(time, weather): """ diff --git a/tests/components/nws/conftest.py b/tests/components/nws/conftest.py index d01201bb484..98ac9191e0d 100644 --- a/tests/components/nws/conftest.py +++ b/tests/components/nws/conftest.py @@ -32,3 +32,21 @@ def mock_simple_nws_config(): instance.station = "ABC" instance.stations = ["ABC"] yield mock_nws + + +@pytest.fixture() +def no_sensor(): + """Remove sensors.""" + with patch( + "homeassistant.components.nws.sensor.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture() +def no_weather(): + """Remove weather.""" + with patch( + "homeassistant.components.nws.weather.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index ae2f826294f..4f4b140dbf9 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -44,6 +44,7 @@ DEFAULT_STATIONS = ["ABC", "XYZ"] DEFAULT_OBSERVATION = { "temperature": 10, "seaLevelPressure": 100000, + "barometricPressure": 100000, "relativeHumidity": 10, "windSpeed": 10, "windDirection": 180, @@ -53,9 +54,45 @@ DEFAULT_OBSERVATION = { "timestamp": "2019-08-12T23:53:00+00:00", "iconTime": "day", "iconWeather": (("Fair/clear", None),), + "dewpoint": 5, + "windChill": 5, + "heatIndex": 15, + "windGust": 20, } -EXPECTED_OBSERVATION_IMPERIAL = { +SENSOR_EXPECTED_OBSERVATION_METRIC = { + "dewpoint": "5", + "temperature": "10", + "windChill": "5", + "heatIndex": "15", + "relativeHumidity": "10", + "windSpeed": "10", + "windGust": "20", + "windDirection": "180", + "barometricPressure": "100000", + "seaLevelPressure": "100000", + "visibility": "10000", +} + +SENSOR_EXPECTED_OBSERVATION_IMPERIAL = { + "dewpoint": str(round(convert_temperature(5, TEMP_CELSIUS, TEMP_FAHRENHEIT))), + "temperature": str(round(convert_temperature(10, TEMP_CELSIUS, TEMP_FAHRENHEIT))), + "windChill": str(round(convert_temperature(5, TEMP_CELSIUS, TEMP_FAHRENHEIT))), + "heatIndex": str(round(convert_temperature(15, TEMP_CELSIUS, TEMP_FAHRENHEIT))), + "relativeHumidity": "10", + "windSpeed": str(round(convert_distance(10, LENGTH_KILOMETERS, LENGTH_MILES))), + "windGust": str(round(convert_distance(20, LENGTH_KILOMETERS, LENGTH_MILES))), + "windDirection": "180", + "barometricPressure": str( + round(convert_pressure(100000, PRESSURE_PA, PRESSURE_INHG), 2) + ), + "seaLevelPressure": str( + round(convert_pressure(100000, PRESSURE_PA, PRESSURE_INHG), 2) + ), + "visibility": str(round(convert_distance(10000, LENGTH_METERS, LENGTH_MILES))), +} + +WEATHER_EXPECTED_OBSERVATION_IMPERIAL = { ATTR_WEATHER_TEMPERATURE: round( convert_temperature(10, TEMP_CELSIUS, TEMP_FAHRENHEIT) ), @@ -72,7 +109,7 @@ EXPECTED_OBSERVATION_IMPERIAL = { ATTR_WEATHER_HUMIDITY: 10, } -EXPECTED_OBSERVATION_METRIC = { +WEATHER_EXPECTED_OBSERVATION_METRIC = { ATTR_WEATHER_TEMPERATURE: 10, ATTR_WEATHER_WIND_BEARING: 180, ATTR_WEATHER_WIND_SPEED: 10, diff --git a/tests/components/nws/test_sensor.py b/tests/components/nws/test_sensor.py new file mode 100644 index 00000000000..44b181b1ec4 --- /dev/null +++ b/tests/components/nws/test_sensor.py @@ -0,0 +1,95 @@ +"""Sensors for National Weather Service (NWS).""" +import pytest + +from homeassistant.components.nws.const import ( + ATTR_LABEL, + ATTRIBUTION, + DOMAIN, + SENSOR_TYPES, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN +from homeassistant.util import slugify +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM + +from tests.common import MockConfigEntry +from tests.components.nws.const import ( + EXPECTED_FORECAST_IMPERIAL, + EXPECTED_FORECAST_METRIC, + NONE_OBSERVATION, + NWS_CONFIG, + SENSOR_EXPECTED_OBSERVATION_IMPERIAL, + SENSOR_EXPECTED_OBSERVATION_METRIC, +) + + +@pytest.mark.parametrize( + "units,result_observation,result_forecast", + [ + ( + IMPERIAL_SYSTEM, + SENSOR_EXPECTED_OBSERVATION_IMPERIAL, + EXPECTED_FORECAST_IMPERIAL, + ), + (METRIC_SYSTEM, SENSOR_EXPECTED_OBSERVATION_METRIC, EXPECTED_FORECAST_METRIC), + ], +) +async def test_imperial_metric( + hass, units, result_observation, result_forecast, mock_simple_nws, no_weather +): + """Test with imperial and metric units.""" + registry = await hass.helpers.entity_registry.async_get_registry() + + for sensor_name, sensor_data in SENSOR_TYPES.items(): + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"35_-75_{sensor_name}", + suggested_object_id=f"abc_{sensor_data[ATTR_LABEL]}", + disabled_by=None, + ) + + hass.config.units = units + entry = MockConfigEntry( + domain=DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + for sensor_name, sensor_data in SENSOR_TYPES.items(): + state = hass.states.get(f"sensor.abc_{slugify(sensor_data[ATTR_LABEL])}") + assert state + assert state.state == result_observation[sensor_name] + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + + +async def test_none_values(hass, mock_simple_nws, no_weather): + """Test with no values.""" + instance = mock_simple_nws.return_value + instance.observation = NONE_OBSERVATION + + registry = await hass.helpers.entity_registry.async_get_registry() + + for sensor_name, sensor_data in SENSOR_TYPES.items(): + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"35_-75_{sensor_name}", + suggested_object_id=f"abc_{sensor_data[ATTR_LABEL]}", + disabled_by=None, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data=NWS_CONFIG, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + for sensor_name, sensor_data in SENSOR_TYPES.items(): + state = hass.states.get(f"sensor.abc_{slugify(sensor_data[ATTR_LABEL])}") + assert state + assert state.state == STATE_UNKNOWN diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 1679e489ab8..78ab7eb4ac5 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -21,23 +21,27 @@ from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.nws.const import ( EXPECTED_FORECAST_IMPERIAL, EXPECTED_FORECAST_METRIC, - EXPECTED_OBSERVATION_IMPERIAL, - EXPECTED_OBSERVATION_METRIC, NONE_FORECAST, NONE_OBSERVATION, NWS_CONFIG, + WEATHER_EXPECTED_OBSERVATION_IMPERIAL, + WEATHER_EXPECTED_OBSERVATION_METRIC, ) @pytest.mark.parametrize( "units,result_observation,result_forecast", [ - (IMPERIAL_SYSTEM, EXPECTED_OBSERVATION_IMPERIAL, EXPECTED_FORECAST_IMPERIAL), - (METRIC_SYSTEM, EXPECTED_OBSERVATION_METRIC, EXPECTED_FORECAST_METRIC), + ( + IMPERIAL_SYSTEM, + WEATHER_EXPECTED_OBSERVATION_IMPERIAL, + EXPECTED_FORECAST_IMPERIAL, + ), + (METRIC_SYSTEM, WEATHER_EXPECTED_OBSERVATION_METRIC, EXPECTED_FORECAST_METRIC), ], ) async def test_imperial_metric( - hass, units, result_observation, result_forecast, mock_simple_nws + hass, units, result_observation, result_forecast, mock_simple_nws, no_sensor ): """Test with imperial and metric units.""" # enable the hourly entity @@ -86,7 +90,7 @@ async def test_imperial_metric( assert forecast[0].get(key) == value -async def test_none_values(hass, mock_simple_nws): +async def test_none_values(hass, mock_simple_nws, no_sensor): """Test with none values in observation and forecast dicts.""" instance = mock_simple_nws.return_value instance.observation = NONE_OBSERVATION @@ -103,7 +107,7 @@ async def test_none_values(hass, mock_simple_nws): state = hass.states.get("weather.abc_daynight") assert state.state == STATE_UNKNOWN data = state.attributes - for key in EXPECTED_OBSERVATION_IMPERIAL: + for key in WEATHER_EXPECTED_OBSERVATION_IMPERIAL: assert data.get(key) is None forecast = data.get(ATTR_FORECAST) @@ -111,7 +115,7 @@ async def test_none_values(hass, mock_simple_nws): assert forecast[0].get(key) is None -async def test_none(hass, mock_simple_nws): +async def test_none(hass, mock_simple_nws, no_sensor): """Test with None as observation and forecast.""" instance = mock_simple_nws.return_value instance.observation = None @@ -130,14 +134,14 @@ async def test_none(hass, mock_simple_nws): assert state.state == STATE_UNKNOWN data = state.attributes - for key in EXPECTED_OBSERVATION_IMPERIAL: + for key in WEATHER_EXPECTED_OBSERVATION_IMPERIAL: assert data.get(key) is None forecast = data.get(ATTR_FORECAST) assert forecast is None -async def test_error_station(hass, mock_simple_nws): +async def test_error_station(hass, mock_simple_nws, no_sensor): """Test error in setting station.""" instance = mock_simple_nws.return_value @@ -155,7 +159,7 @@ async def test_error_station(hass, mock_simple_nws): assert hass.states.get("weather.abc_daynight") is None -async def test_entity_refresh(hass, mock_simple_nws): +async def test_entity_refresh(hass, mock_simple_nws, no_sensor): """Test manual refresh.""" instance = mock_simple_nws.return_value @@ -184,7 +188,7 @@ async def test_entity_refresh(hass, mock_simple_nws): instance.update_forecast_hourly.assert_called_once() -async def test_error_observation(hass, mock_simple_nws): +async def test_error_observation(hass, mock_simple_nws, no_sensor): """Test error during update observation.""" utc_time = dt_util.utcnow() with patch("homeassistant.components.nws.utcnow") as mock_utc, patch( @@ -248,7 +252,7 @@ async def test_error_observation(hass, mock_simple_nws): assert state.state == STATE_UNAVAILABLE -async def test_error_forecast(hass, mock_simple_nws): +async def test_error_forecast(hass, mock_simple_nws, no_sensor): """Test error during update forecast.""" instance = mock_simple_nws.return_value instance.update_forecast.side_effect = aiohttp.ClientError @@ -279,7 +283,7 @@ async def test_error_forecast(hass, mock_simple_nws): assert state.state == ATTR_CONDITION_SUNNY -async def test_error_forecast_hourly(hass, mock_simple_nws): +async def test_error_forecast_hourly(hass, mock_simple_nws, no_sensor): """Test error during update forecast hourly.""" instance = mock_simple_nws.return_value instance.update_forecast_hourly.side_effect = aiohttp.ClientError @@ -320,7 +324,7 @@ async def test_error_forecast_hourly(hass, mock_simple_nws): assert state.state == ATTR_CONDITION_SUNNY -async def test_forecast_hourly_disable_enable(hass, mock_simple_nws): +async def test_forecast_hourly_disable_enable(hass, mock_simple_nws, no_sensor): """Test error during update forecast hourly.""" entry = MockConfigEntry( domain=nws.DOMAIN,