-
-
Notifications
You must be signed in to change notification settings - Fork 37.5k
Add NWS weather #23647
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Add NWS weather #23647
Changes from all commits
Commits
Show all changes
38 commits
Select commit
Hold shift + click to select a range
86fc312
Add nws weather.
MatthewFlamm 47b8b73
Hassfest
MatthewFlamm 892b8c2
Address multiple comments
MatthewFlamm 1f437a3
Add NWS icon weather code link
MatthewFlamm 1e8bae2
Add metar fallback.
MatthewFlamm d286784
only get 1 observation - we dont use more than 1
MatthewFlamm 4d90b20
add mocked metar for tests
MatthewFlamm c69e23d
lint
MatthewFlamm 5444e13
mock metar package for all tests
MatthewFlamm e7815a9
add check for metar attributes
MatthewFlamm 20d697d
catch errors in setup
MatthewFlamm 7f71143
add timeout error
MatthewFlamm 63055f9
handle request exceptions
MatthewFlamm ef687d3
check and test for missing observations
MatthewFlamm b5727c8
refactor to new pynws
MatthewFlamm fd3a90a
change to simpler api
MatthewFlamm 4946d91
Make py3.5 compatible
MatthewFlamm 3a727aa
bump pynws version
MatthewFlamm 6f0b414
gen_requirements
MatthewFlamm 34190df
fix wind bearing observation
MatthewFlamm 9b4326a
get uptodate-black
MatthewFlamm 3dc4d84
Revert "Make py3.5 compatible"
MatthewFlamm 5593490
Precommit black missed a file?
MatthewFlamm 53d7c74
black test
MatthewFlamm 6f6071b
add exceptional weather condition
MatthewFlamm a37cea4
bump pynws version
MatthewFlamm dbf6215
update requirements_all
MatthewFlamm c94ac01
address comments
MatthewFlamm 1d39d86
Merge branch 'add_nws' of github.com:MatthewFlamm/home-assistant into…
MatthewFlamm 53b78b3
move observation and forecast outside try-except-else
MatthewFlamm 69a3a34
Revert "move observation and forecast outside try-except-else"
MatthewFlamm 4dab748
remove else from update forecast block
MatthewFlamm 2dfcaaa
remove unneeded ConfigEntryNotReady import
MatthewFlamm b97f6fe
add scan_interval, reduce min_time_between_updates
MatthewFlamm f4241b3
pytest tests
MatthewFlamm 7ef706a
lint test docstring
MatthewFlamm 912d672
use async await
MatthewFlamm dc76758
lat and lon inclusive in config
MatthewFlamm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """NWS Integration.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
|
||
| _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) | ||
|
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) | ||
|
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 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.