Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
86fc312
Add nws weather.
MatthewFlamm May 3, 2019
47b8b73
Hassfest
MatthewFlamm May 3, 2019
892b8c2
Address multiple comments
MatthewFlamm May 4, 2019
1f437a3
Add NWS icon weather code link
MatthewFlamm May 4, 2019
1e8bae2
Add metar fallback.
MatthewFlamm May 19, 2019
d286784
only get 1 observation - we dont use more than 1
MatthewFlamm May 20, 2019
4d90b20
add mocked metar for tests
MatthewFlamm May 20, 2019
c69e23d
lint
MatthewFlamm May 20, 2019
5444e13
mock metar package for all tests
MatthewFlamm May 20, 2019
e7815a9
add check for metar attributes
MatthewFlamm May 28, 2019
20d697d
catch errors in setup
MatthewFlamm May 29, 2019
7f71143
add timeout error
MatthewFlamm May 29, 2019
63055f9
handle request exceptions
MatthewFlamm May 31, 2019
ef687d3
check and test for missing observations
MatthewFlamm Jun 1, 2019
b5727c8
refactor to new pynws
MatthewFlamm Jul 4, 2019
fd3a90a
change to simpler api
MatthewFlamm Jul 18, 2019
4946d91
Make py3.5 compatible
MatthewFlamm Jul 18, 2019
3a727aa
bump pynws version
MatthewFlamm Jul 18, 2019
6f0b414
gen_requirements
MatthewFlamm Jul 18, 2019
34190df
fix wind bearing observation
MatthewFlamm Jul 21, 2019
9b4326a
get uptodate-black
MatthewFlamm Aug 3, 2019
3dc4d84
Revert "Make py3.5 compatible"
MatthewFlamm Aug 3, 2019
5593490
Precommit black missed a file?
MatthewFlamm Aug 3, 2019
53d7c74
black test
MatthewFlamm Aug 3, 2019
6f6071b
add exceptional weather condition
MatthewFlamm Aug 3, 2019
a37cea4
bump pynws version
MatthewFlamm Aug 8, 2019
dbf6215
update requirements_all
MatthewFlamm Aug 8, 2019
c94ac01
address comments
MatthewFlamm Aug 10, 2019
1d39d86
Merge branch 'add_nws' of github.com:MatthewFlamm/home-assistant into…
MatthewFlamm Aug 10, 2019
53b78b3
move observation and forecast outside try-except-else
MatthewFlamm Aug 10, 2019
69a3a34
Revert "move observation and forecast outside try-except-else"
MatthewFlamm Aug 10, 2019
4dab748
remove else from update forecast block
MatthewFlamm Aug 10, 2019
2dfcaaa
remove unneeded ConfigEntryNotReady import
MatthewFlamm Aug 10, 2019
b97f6fe
add scan_interval, reduce min_time_between_updates
MatthewFlamm Aug 10, 2019
f4241b3
pytest tests
MatthewFlamm Aug 14, 2019
7ef706a
lint test docstring
MatthewFlamm Aug 15, 2019
912d672
use async await
MatthewFlamm Aug 16, 2019
dc76758
lat and lon inclusive in config
MatthewFlamm Aug 16, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,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
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/nws/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""NWS Integration."""
8 changes: 8 additions & 0 deletions homeassistant/components/nws/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"domain": "nws",
"name": "National Weather Service",
"documentation": "https://www.home-assistant.io/components/nws",
"dependencies": [],
"codeowners": ["@MatthewFlamm"],
"requirements": ["pynws==0.7.4"]
}
378 changes: 378 additions & 0 deletions homeassistant/components/nws/weather.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,378 @@
"""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 (
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.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 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)
Comment thread
MartinHjelmare marked this conversation as resolved.

_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)
Comment thread
MatthewFlamm marked this conversation as resolved.
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)
Comment thread
MartinHjelmare marked this conversation as resolved.
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
Loading