1
mirror of https://github.com/home-assistant/core synced 2024-08-02 23:40:32 +02:00

Add nws sensor platform (#45027)

* Resolve rebase conflict.

Remove logging

* lint: fix elif after return

* fix attribution

* add tests for None valuea

* Remove Entity import

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Import SensorEntity

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* Inherit SensorEntity

Co-authored-by: Erik Montnemery <erik@montnemery.com>

* remove unused logging

* Use CoordinatorEntity

* Use type instead of name.

* add all entities

* add nice rounding to temperature and humidity

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
MatthewFlamm 2021-04-01 12:50:37 -04:00 committed by GitHub
parent 9f481e1642
commit f8f0495319
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 435 additions and 23 deletions

View File

@ -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)

View File

@ -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,
},
}

View File

@ -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

View File

@ -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):
"""

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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,