core/homeassistant/components/nws/weather.py

379 lines
12 KiB
Python

"""Support for NWS weather service."""
from collections import OrderedDict
from datetime import timedelta
from json import JSONDecodeError
import logging
import aiohttp
from pynws import SimpleNWS
import voluptuous as vol
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_TEMP,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
ATTR_FORECAST_WIND_SPEED,
PLATFORM_SCHEMA,
WeatherEntity,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_MODE,
CONF_NAME,
LENGTH_KILOMETERS,
LENGTH_METERS,
LENGTH_MILES,
PRESSURE_HPA,
PRESSURE_INHG,
PRESSURE_PA,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import Throttle
from homeassistant.util.distance import convert as convert_distance
from homeassistant.util.pressure import convert as convert_pressure
from homeassistant.util.temperature import convert as convert_temperature
_LOGGER = logging.getLogger(__name__)
ATTRIBUTION = "Data from National Weather Service/NOAA"
SCAN_INTERVAL = timedelta(minutes=15)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
CONF_STATION = "station"
ATTR_FORECAST_DETAIL_DESCRIPTION = "detailed_description"
ATTR_FORECAST_PRECIP_PROB = "precipitation_probability"
ATTR_FORECAST_DAYTIME = "daytime"
# Ordered so that a single condition can be chosen from multiple weather codes.
# Catalog of NWS icon weather codes listed at:
# https://api.weather.gov/icons
CONDITION_CLASSES = OrderedDict(
[
(
"exceptional",
[
"Tornado",
"Hurricane conditions",
"Tropical storm conditions",
"Dust",
"Smoke",
"Haze",
"Hot",
"Cold",
],
),
("snowy", ["Snow", "Sleet", "Blizzard"]),
(
"snowy-rainy",
[
"Rain/snow",
"Rain/sleet",
"Freezing rain/snow",
"Freezing rain",
"Rain/freezing rain",
],
),
("hail", []),
(
"lightning-rainy",
[
"Thunderstorm (high cloud cover)",
"Thunderstorm (medium cloud cover)",
"Thunderstorm (low cloud cover)",
],
),
("lightning", []),
("pouring", []),
(
"rainy",
[
"Rain",
"Rain showers (high cloud cover)",
"Rain showers (low cloud cover)",
],
),
("windy-variant", ["Mostly cloudy and windy", "Overcast and windy"]),
(
"windy",
[
"Fair/clear and windy",
"A few clouds and windy",
"Partly cloudy and windy",
],
),
("fog", ["Fog/mist"]),
("clear", ["Fair/clear"]), # sunny and clear-night
("cloudy", ["Mostly cloudy", "Overcast"]),
("partlycloudy", ["A few clouds", "Partly cloudy"]),
]
)
ERRORS = (aiohttp.ClientError, JSONDecodeError)
FORECAST_MODE = ["daynight", "hourly"]
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_NAME): cv.string,
vol.Inclusive(
CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together"
): cv.latitude,
vol.Inclusive(
CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together"
): cv.longitude,
vol.Optional(CONF_MODE, default="daynight"): vol.In(FORECAST_MODE),
vol.Optional(CONF_STATION): cv.string,
vol.Required(CONF_API_KEY): cv.string,
}
)
def convert_condition(time, weather):
"""
Convert NWS codes to HA condition.
Choose first condition in CONDITION_CLASSES that exists in weather code.
If no match is found, return first condition from NWS
"""
conditions = [w[0] for w in weather]
prec_probs = [w[1] or 0 for w in weather]
# Choose condition with highest priority.
cond = next(
(
key
for key, value in CONDITION_CLASSES.items()
if any(condition in value for condition in conditions)
),
conditions[0],
)
if cond == "clear":
if time == "day":
return "sunny", max(prec_probs)
if time == "night":
return "clear-night", max(prec_probs)
return cond, max(prec_probs)
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the NWS weather platform."""
latitude = config.get(CONF_LATITUDE, hass.config.latitude)
longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
station = config.get(CONF_STATION)
api_key = config[CONF_API_KEY]
mode = config[CONF_MODE]
websession = async_get_clientsession(hass)
# ID request as being from HA, pynws prepends the api_key in addition
api_key_ha = f"{api_key} homeassistant"
nws = SimpleNWS(latitude, longitude, api_key_ha, mode, websession)
_LOGGER.debug("Setting up station: %s", station)
try:
await nws.set_station(station)
except ERRORS as status:
_LOGGER.error(
"Error getting station list for %s: %s", (latitude, longitude), status
)
raise PlatformNotReady
_LOGGER.debug("Station list: %s", nws.stations)
_LOGGER.debug(
"Initialized for coordinates %s, %s -> station %s",
latitude,
longitude,
nws.station,
)
async_add_entities([NWSWeather(nws, mode, hass.config.units, config)], True)
class NWSWeather(WeatherEntity):
"""Representation of a weather condition."""
def __init__(self, nws, mode, units, config):
"""Initialise the platform with a data instance and station name."""
self.nws = nws
self.station_name = config.get(CONF_NAME, self.nws.station)
self.is_metric = units.is_metric
self.mode = mode
self.observation = None
self._forecast = None
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self):
"""Update Condition."""
_LOGGER.debug("Updating station observations %s", self.nws.station)
try:
await self.nws.update_observation()
except ERRORS as status:
_LOGGER.error(
"Error updating observation from station %s: %s",
self.nws.station,
status,
)
else:
self.observation = self.nws.observation
_LOGGER.debug("Updating forecast")
try:
await self.nws.update_forecast()
except ERRORS as status:
_LOGGER.error(
"Error updating forecast from station %s: %s", self.nws.station, status
)
return
self._forecast = self.nws.forecast
@property
def attribution(self):
"""Return the attribution."""
return ATTRIBUTION
@property
def name(self):
"""Return the name of the station."""
return self.station_name
@property
def temperature(self):
"""Return the current temperature."""
temp_c = None
if self.observation:
temp_c = self.observation.get("temperature")
if temp_c:
return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT)
return None
@property
def pressure(self):
"""Return the current pressure."""
pressure_pa = None
if self.observation:
pressure_pa = self.observation.get("seaLevelPressure")
if pressure_pa is None:
return None
if self.is_metric:
pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA)
pressure = round(pressure)
else:
pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_INHG)
pressure = round(pressure, 2)
return pressure
@property
def humidity(self):
"""Return the name of the sensor."""
humidity = None
if self.observation:
humidity = self.observation.get("relativeHumidity")
return humidity
@property
def wind_speed(self):
"""Return the current windspeed."""
wind_m_s = None
if self.observation:
wind_m_s = self.observation.get("windSpeed")
if wind_m_s is None:
return None
wind_m_hr = wind_m_s * 3600
if self.is_metric:
wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_KILOMETERS)
else:
wind = convert_distance(wind_m_hr, LENGTH_METERS, LENGTH_MILES)
return round(wind)
@property
def wind_bearing(self):
"""Return the current wind bearing (degrees)."""
wind_bearing = None
if self.observation:
wind_bearing = self.observation.get("windDirection")
return wind_bearing
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return TEMP_FAHRENHEIT
@property
def condition(self):
"""Return current condition."""
weather = None
if self.observation:
weather = self.observation.get("iconWeather")
time = self.observation.get("iconTime")
if weather:
cond, _ = convert_condition(time, weather)
return cond
return None
@property
def visibility(self):
"""Return visibility."""
vis_m = None
if self.observation:
vis_m = self.observation.get("visibility")
if vis_m is None:
return None
if self.is_metric:
vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_KILOMETERS)
else:
vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_MILES)
return round(vis, 0)
@property
def forecast(self):
"""Return forecast."""
if self._forecast is None:
return None
forecast = []
for forecast_entry in self._forecast:
data = {
ATTR_FORECAST_DETAIL_DESCRIPTION: forecast_entry.get(
"detailedForecast"
),
ATTR_FORECAST_TEMP: forecast_entry.get("temperature"),
ATTR_FORECAST_TIME: forecast_entry.get("startTime"),
}
if self.mode == "daynight":
data[ATTR_FORECAST_DAYTIME] = forecast_entry.get("isDaytime")
time = forecast_entry.get("iconTime")
weather = forecast_entry.get("iconWeather")
if time and weather:
cond, precip = convert_condition(time, weather)
else:
cond, precip = None, None
data[ATTR_FORECAST_CONDITION] = cond
data[ATTR_FORECAST_PRECIP_PROB] = precip
data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing")
wind_speed = forecast_entry.get("windSpeedAvg")
if wind_speed:
if self.is_metric:
data[ATTR_FORECAST_WIND_SPEED] = round(
convert_distance(wind_speed, LENGTH_MILES, LENGTH_KILOMETERS)
)
else:
data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed)
else:
data[ATTR_FORECAST_WIND_SPEED] = None
forecast.append(data)
return forecast