From de3d28d9d5bd5dd69cf9f84d021d683da2c322d6 Mon Sep 17 00:00:00 2001 From: Ryan Claussen Date: Tue, 16 Jul 2019 11:03:06 -0500 Subject: [PATCH] Add severe weather sensor to Dark Sky (#22701) * Add severe weather alert sensor to Dark Sky * fixup test case * address review comments and fixup testcases * address comments, fix assertion order * remove extra line * remove index increment --- homeassistant/components/darksky/sensor.py | 96 +++++++++++++++++++++- tests/components/darksky/test_sensor.py | 30 ++++++- tests/fixtures/darksky.json | 29 +++++-- 3 files changed, 146 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 63c2f782d17..394541378e4 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -128,6 +128,8 @@ SENSOR_TYPES = { 'mdi:white-balance-sunny', ['daily']], 'sunset_time': ['Sunset', None, None, None, None, None, 'mdi:weather-night', ['daily']], + 'alerts': ['Alerts', None, None, None, None, None, + 'mdi:alert-circle-outline', []] } CONDITION_PICTURES = { @@ -164,6 +166,16 @@ LANGUAGE_CODES = [ ALLOWED_UNITS = ['auto', 'si', 'us', 'ca', 'uk', 'uk2'] +ALERTS_ATTRS = [ + 'time', + 'description', + 'expires', + 'severity', + 'uri', + 'regions', + 'title' +] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), @@ -223,7 +235,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.warning("Monitored condition %s is deprecated", variable) if (not SENSOR_TYPES[variable][7] or 'currently' in SENSOR_TYPES[variable][7]): - sensors.append(DarkSkySensor(forecast_data, variable, name)) + if variable == 'alerts': + sensors.append(DarkSkyAlertSensor( + forecast_data, variable, name)) + else: + sensors.append(DarkSkySensor(forecast_data, variable, name)) + if forecast is not None and 'daily' in SENSOR_TYPES[variable][7]: for forecast_day in forecast: sensors.append(DarkSkySensor( @@ -390,6 +407,77 @@ class DarkSkySensor(Entity): return state +class DarkSkyAlertSensor(Entity): + """Implementation of a Dark Sky sensor.""" + + def __init__(self, forecast_data, sensor_type, name): + """Initialize the sensor.""" + self.client_name = name + self._name = SENSOR_TYPES[sensor_type][0] + self.forecast_data = forecast_data + self.type = sensor_type + self._state = None + self._icon = None + self._alerts = None + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format( + self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + if self._state is not None and self._state > 0: + return "mdi:alert-circle" + return "mdi:alert-circle-outline" + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._alerts + + def update(self): + """Get the latest data from Dark Sky and updates the states.""" + # Call the API for new forecast data. Each sensor will re-trigger this + # same exact call, but that's fine. We cache results for a short period + # of time to prevent hitting API limits. Note that Dark Sky will + # charge users for too many calls in 1 day, so take care when updating. + self.forecast_data.update() + self.forecast_data.update_alerts() + alerts = self.forecast_data.data_alerts + self._state = self.get_state(alerts) + + def get_state(self, data): + """ + Return a new state based on the type. + + If the sensor type is unknown, the current state is returned. + """ + alerts = {} + if data is None: + self._alerts = alerts + return data + + multiple_alerts = len(data) > 1 + for i, alert in enumerate(data): + for attr in ALERTS_ATTRS: + if multiple_alerts: + dkey = attr + '_' + str(i) + else: + dkey = attr + alerts[dkey] = getattr(alert, attr) + self._alerts = alerts + + return len(data) + + def convert_to_camel(data): """ Convert snake case (foo_bar_bat) to camel case (fooBarBat). @@ -418,6 +506,7 @@ class DarkSkyData: self.data_minutely = None self.data_hourly = None self.data_daily = None + self.data_alerts = None # Apply throttling to methods using configured interval self.update = Throttle(interval)(self._update) @@ -425,6 +514,7 @@ class DarkSkyData: self.update_minutely = Throttle(interval)(self._update_minutely) self.update_hourly = Throttle(interval)(self._update_hourly) self.update_daily = Throttle(interval)(self._update_daily) + self.update_alerts = Throttle(interval)(self._update_alerts) def _update(self): """Get the latest data from Dark Sky.""" @@ -454,3 +544,7 @@ class DarkSkyData: def _update_daily(self): """Update daily data.""" self.data_daily = self.data and self.data.daily() + + def _update_alerts(self): + """Update alerts data.""" + self.data_alerts = self.data and self.data.alerts() diff --git a/tests/components/darksky/test_sensor.py b/tests/components/darksky/test_sensor.py index ffb90d8474b..8bc659bb628 100644 --- a/tests/components/darksky/test_sensor.py +++ b/tests/components/darksky/test_sensor.py @@ -21,7 +21,8 @@ VALID_CONFIG_MINIMAL = { 'api_key': 'foo', 'forecast': [1, 2], 'hourly_forecast': [1, 2], - 'monitored_conditions': ['summary', 'icon', 'temperature_high'], + 'monitored_conditions': ['summary', 'icon', 'temperature_high', + 'alerts'], 'scan_interval': timedelta(seconds=120), } } @@ -47,7 +48,7 @@ VALID_CONFIG_LANG_DE = { 'language': 'de', 'monitored_conditions': ['summary', 'icon', 'temperature_high', 'minutely_summary', 'hourly_summary', - 'daily_summary', 'humidity', ], + 'daily_summary', 'humidity', 'alerts'], 'scan_interval': timedelta(seconds=120), } } @@ -64,6 +65,18 @@ INVALID_CONFIG_LANG = { } } +VALID_CONFIG_ALERTS = { + 'sensor': { + 'platform': 'darksky', + 'api_key': 'foo', + 'forecast': [1, 2], + 'hourly_forecast': [1, 2], + 'monitored_conditions': ['summary', 'icon', 'temperature_high', + 'alerts'], + 'scan_interval': timedelta(seconds=120), + } +} + def load_forecastMock(key, lat, lon, units, lang): # pylint: disable=invalid-name @@ -149,6 +162,15 @@ class TestDarkSkySetup(unittest.TestCase): ) assert not response + @MockDependency('forecastio') + @patch('forecastio.load_forecast', new=load_forecastMock) + def test_setup_with_alerts_config(self, mock_forecastio): + """Test the platform setup with alert configuration.""" + setup_component(self.hass, 'sensor', VALID_CONFIG_ALERTS) + + state = self.hass.states.get('sensor.dark_sky_alerts') + assert state.state == '0' + @requests_mock.Mocker() @patch('forecastio.api.get_forecast', wraps=forecastio.api.get_forecast) def test_setup(self, mock_req, mock_get_forecast): @@ -161,10 +183,12 @@ class TestDarkSkySetup(unittest.TestCase): assert mock_get_forecast.called assert mock_get_forecast.call_count == 1 - assert len(self.hass.states.entity_ids()) == 12 + assert len(self.hass.states.entity_ids()) == 13 state = self.hass.states.get('sensor.dark_sky_summary') assert state is not None assert state.state == 'Clear' assert state.attributes.get('friendly_name') == \ 'Dark Sky Summary' + state = self.hass.states.get('sensor.dark_sky_alerts') + assert state.state == '2' diff --git a/tests/fixtures/darksky.json b/tests/fixtures/darksky.json index 01c66cae078..1ed9499fbc2 100644 --- a/tests/fixtures/darksky.json +++ b/tests/fixtures/darksky.json @@ -1,11 +1,30 @@ { "alerts": [ { - "description": "...BEACH HAZARDS STATEMENT REMAINS IN EFFECT UNTIL 9 PM PDT THIS\nEVENING...\n* HAZARDS...STRONG RIP CURRENTS AND LARGE SHORE BREAK.\n* TIMING...THROUGH THIS EVENING.\n* LOCATION...COASTLINE FROM SONOMA COUNTY SOUTH THROUGH MONTEREY\nCOUNTY. IN PARTICULAR SOUTHWEST FACING BEACHES...INCLUDING BUT\nNOT LIMITED TO...STINSON BEACH...SANTA CRUZ BOARDWALK BEACH\nAND TWIN LAKES BEACH.\n* POTENTIAL IMPACTS...STRONG RIP CURRENTS CAN PULL EVEN THE\nSTRONGEST SWIMMERS AWAY FROM SHORE. LARGE SHORE BREAK CAN\nRESULT IN SERIOUS NECK AND BACK INJURIES.\n", - "expires": 1464926400, - "time": 1464904560, - "title": "Beach Hazards Statement for San Francisco, CA", - "uri": "http://alerts.weather.gov/cap/wwacapget.php?x=CA1256018894B0.BeachHazardsStatement.125601952900CA.MTRCFWMTR.aba33b94542100e878a14a443e621995" + "title": "Winter Storm Watch", + "regions": [ + "Burney Basin / Eastern Shasta County", + "Mountains Southwestern Shasta County to Northern Lake County", + "West Slope Northern Sierra Nevada", + "Western Plumas County/Lassen Park" + ], + "severity": "watch", + "time": 1554422400, + "expires": 1554552000, + "description": "...Hazardous mountain travel expected over 6000 feet Thursday through late Friday night... .Snow is expected to begin Thursday afternoon increasing in intensity during the evening hours. Heavy snow and gusty winds are forecast Friday afternoon which will lead to hazardous travel over the mountains. Snow will begin to diminish during the late evening and overnight hours Friday night into Saturday morning. ...WINTER STORM WATCH IN EFFECT FROM THURSDAY AFTERNOON THROUGH LATE FRIDAY NIGHT... * WHAT...Periods of heavy snow possible. Plan on difficult travel conditions, including during the afternoon and evening hours on Friday. Total snow accumulations of 8 to 12 inches, with localized amounts up to 2 and a half feet possible. * WHERE...Western Plumas County/Lassen Park and West Slope Northern Sierra Nevada. * WHEN...From Thursday afternoon through late Friday night. * ADDITIONAL DETAILS...Be prepared for reduced visibilities at times. PRECAUTIONARY/PREPAREDNESS ACTIONS... A Winter Storm Watch means there is potential for significant snow, sleet or ice accumulations that may impact travel. Continue to monitor the latest forecasts.\n", + "uri": "https://alerts.weather.gov/cap/wwacapget.php?x=CA125CF1CFB088.WinterStormWatch.125CF1FC1240CA.STOWSWSTO.1fc288d0ac8e931cb17b5f5be62efff0" + }, + { + "title": "Red Flag Warning", + "regions": [ + "Guadalupe Mountains", + "Southeast Plains" + ], + "severity": "warning", + "time": 1554300000, + "expires": 1554350400, + "description": "...RED FLAG WARNING IN EFFECT FROM 9 AM CDT /8 AM MDT/ THIS MORNING TO 11 PM CDT /10 PM MDT/ THIS EVENING FOR RELATIVE HUMIDITY OF 15% OR LESS, 20 FT WINDS OF 20 MPH OR MORE, AND HIGH TO EXTREME FIRE DANGER FOR THE GUADALUPE, DAVIS, AND APACHE MOUNTAINS, SOUTHEAST NEW MEXICO PLAINS, REEVES COUNTY AND THE UPPER TRANS PECOS, AND VAN HORN AND THE HIGHWAY 54 CORRIDOR... .Critical fire weather conditions are expected today and this evening across Southeastern New Mexico and areas south as far as the Davis and Apache Mountains. Southwest to west 20 ft winds will increase as an upper-level disturbance passes through the region. Combined with unseasonably warm temperatures, consequent low relative humidity, and cured fuels, potential for fire growth will increase. ...RED FLAG WARNING IN EFFECT FROM 9 AM CDT /8 AM MDT/ THIS MORNING TO 11 PM CDT /10 PM MDT/ THIS EVENING FOR RELATIVE HUMIDITY OF 15% OR LESS, 20 FT WINDS OF 20 MPH OR MORE, AND HIGH TO EXTREME FIRE DANGER... * WIND...Mountains...West 20 to 25 mph increasing to 30 to 40 mph in the afternoon. Plains...Southwest 20 to 25 mph. * HUMIDITY...8% to 15%. * IMPACTS...any fires that develop will likely spread rapidly. Outdoor burning is not recommended.\n", + "uri": "https://alerts.weather.gov/cap/wwacapget.php?x=NM125CF1CDEA3C.RedFlagWarning.125CF1DC5540NM.MAFRFWMAF.085066acdafda6aecf61d10224f5133e" } ], "currently": {