diff --git a/.coveragerc b/.coveragerc index c81186c3165..4ab2bb25637 100644 --- a/.coveragerc +++ b/.coveragerc @@ -660,6 +660,7 @@ omit = homeassistant/components/vacuum/xiaomi_miio.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py + homeassistant/components/weather/darksky.py homeassistant/components/weather/metoffice.py homeassistant/components/weather/openweathermap.py homeassistant/components/weather/yweather.py diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py new file mode 100644 index 00000000000..4c7512969f6 --- /dev/null +++ b/homeassistant/components/weather/darksky.py @@ -0,0 +1,189 @@ +""" +Patform for retrieving meteorological data from Dark Sky. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/ +""" +from datetime import datetime, timedelta +import logging + +from requests.exceptions import ( + ConnectionError as ConnectError, HTTPError, Timeout) +import voluptuous as vol + +from homeassistant.components.weather import ( + ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) +from homeassistant.const import ( + CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS, + TEMP_FAHRENHEIT) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['python-forecastio==1.3.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = "Powered by Dark Sky" + +ATTR_DAILY_FORECAST_SUMMARY = 'daily_forecast_summary' +ATTR_HOURLY_FORECAST_SUMMARY = 'hourly_forecast_summary' + +CONF_UNITS = 'units' + +DEFAULT_NAME = 'Dark Sky' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=3) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Dark Sky weather.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + name = config.get(CONF_NAME) + + units = config.get(CONF_UNITS) + if not units: + units = 'si' if hass.config.units.is_metric else 'us' + + dark_sky = DarkSkyData( + config.get(CONF_API_KEY), latitude, longitude, units) + + add_devices([DarkSkyWeather(name, dark_sky)], True) + + +class DarkSkyWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, name, dark_sky): + """Initialize Dark Sky weather.""" + self._name = name + self._dark_sky = dark_sky + + self._ds_data = None + self._ds_currently = None + self._ds_hourly = None + self._ds_daily = None + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def temperature(self): + """Return the temperature.""" + return self._ds_currently.get('temperature') + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_FAHRENHEIT if 'us' in self._dark_sky.units \ + else TEMP_CELSIUS + + @property + def humidity(self): + """Return the humidity.""" + return self._ds_currently.get('humidity') * 100.0 + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._ds_currently.get('windSpeed') + + @property + def pressure(self): + """Return the pressure.""" + return self._ds_currently.get('pressure') + + @property + def condition(self): + """Return the weather condition.""" + return self._ds_currently.get('summary') + + @property + def forecast(self): + """Return the forecast array.""" + return [{ + ATTR_FORECAST_TIME: + datetime.fromtimestamp(entry.d.get('time')).isoformat(), + ATTR_FORECAST_TEMP: entry.d.get('temperature')} + for entry in self._ds_hourly.data] + + @property + def hourly_forecast_summary(self): + """Return a summary of the hourly forecast.""" + return self._ds_hourly.summary + + @property + def daily_forecast_summary(self): + """Return a summary of the daily forecast.""" + return self._ds_daily.summary + + @property + def state_attributes(self): + """Return the state attributes.""" + attrs = super().state_attributes + attrs.update({ + ATTR_DAILY_FORECAST_SUMMARY: self.daily_forecast_summary, + ATTR_HOURLY_FORECAST_SUMMARY: self.hourly_forecast_summary + }) + return attrs + + def update(self): + """Get the latest data from Dark Sky.""" + self._dark_sky.update() + + self._ds_data = self._dark_sky.data + self._ds_currently = self._dark_sky.currently.d + self._ds_hourly = self._dark_sky.hourly + self._ds_daily = self._dark_sky.daily + + +class DarkSkyData(object): + """Get the latest data from Dark Sky.""" + + def __init__(self, api_key, latitude, longitude, units): + """Initialize the data object.""" + self._api_key = api_key + self.latitude = latitude + self.longitude = longitude + self.requested_units = units + + self.data = None + self.currently = None + self.hourly = None + self.daily = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Dark Sky.""" + import forecastio + + try: + self.data = forecastio.load_forecast( + self._api_key, self.latitude, self.longitude, + units=self.requested_units) + self.currently = self.data.currently() + self.hourly = self.data.hourly() + self.daily = self.data.daily() + except (ConnectError, HTTPError, Timeout, ValueError) as error: + _LOGGER.error("Unable to connect to Dark Sky. %s", error) + self.data = None + + @property + def units(self): + """Get the unit system of returned data.""" + return self.data.json.get('flags').get('units') diff --git a/requirements_all.txt b/requirements_all.txt index 38cff1464da..bc37b9de96e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -864,6 +864,7 @@ python-ecobee-api==0.0.14 python-etherscan-api==0.0.1 # homeassistant.components.sensor.darksky +# homeassistant.components.weather.darksky python-forecastio==1.3.5 # homeassistant.components.gc100 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f243ccb2b54..4ab838211a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -134,6 +134,7 @@ pymonoprice==0.3 pynx584==0.4 # homeassistant.components.sensor.darksky +# homeassistant.components.weather.darksky python-forecastio==1.3.5 # homeassistant.components.sensor.whois diff --git a/tests/components/weather/test_darksky.py b/tests/components/weather/test_darksky.py new file mode 100644 index 00000000000..7faa033e0a8 --- /dev/null +++ b/tests/components/weather/test_darksky.py @@ -0,0 +1,51 @@ +"""The tests for the Dark Sky weather component.""" +import re +import unittest +from unittest.mock import patch + +import forecastio +import requests_mock + +from homeassistant.components import weather +from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.setup import setup_component + +from tests.common import load_fixture, get_test_home_assistant + + +class TestDarkSky(unittest.TestCase): + """Test the Dark Sky weather component.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + self.lat = self.hass.config.latitude = 37.8267 + self.lon = self.hass.config.longitude = -122.423 + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @requests_mock.Mocker() + @patch('forecastio.api.get_forecast', wraps=forecastio.api.get_forecast) + def test_setup(self, mock_req, mock_get_forecast): + """Test for successfully setting up the forecast.io platform.""" + uri = (r'https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/' + r'(-?\d+\.?\d*),(-?\d+\.?\d*)') + mock_req.get(re.compile(uri), + text=load_fixture('darksky.json')) + + self.assertTrue(setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'test', + 'platform': 'darksky', + 'api_key': 'foo', + } + })) + + self.assertTrue(mock_get_forecast.called) + self.assertEqual(mock_get_forecast.call_count, 1) + + state = self.hass.states.get('weather.test') + self.assertEqual(state.state, 'Clear')