379 lines
12 KiB
Python
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
|