diff --git a/.coveragerc b/.coveragerc index d3f142d6efe..d0a3f34223a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -314,6 +314,7 @@ omit = homeassistant/components/thermostat/proliphix.py homeassistant/components/thermostat/radiotherm.py homeassistant/components/upnp.py + homeassistant/components/weather/openweathermap.py homeassistant/components/zeroconf.py diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py new file mode 100644 index 00000000000..dcb5dc49233 --- /dev/null +++ b/homeassistant/components/weather/__init__.py @@ -0,0 +1,129 @@ +""" +Weather component that handles meteorological data for your location. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/weather/ +""" +import logging + +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.util.temperature import convert as convert_temperature +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = [] +DOMAIN = 'weather' + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + +ATTR_CONDITION_CLASS = 'condition_class' +ATTR_WEATHER_ATTRIBUTION = 'attribution' +ATTR_WEATHER_HUMIDITY = 'humidity' +ATTR_WEATHER_OZONE = 'ozone' +ATTR_WEATHER_PRESSURE = 'pressure' +ATTR_WEATHER_TEMPERATURE = 'temperature' +ATTR_WEATHER_WIND_BEARING = 'wind_bearing' +ATTR_WEATHER_WIND_SPEED = 'wind_speed' + + +def setup(hass, config): + """Setup the weather component.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + component.setup(config) + + return True + + +# pylint: disable=no-member, no-self-use, too-many-return-statements +class WeatherEntity(Entity): + """ABC for a weather data.""" + + @property + def temperature(self): + """Return the platform temperature.""" + raise NotImplementedError() + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + raise NotImplementedError() + + @property + def pressure(self): + """Return the pressure.""" + return None + + @property + def humidity(self): + """Return the humidity.""" + raise NotImplementedError() + + @property + def wind_speed(self): + """Return the wind speed.""" + return None + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return None + + @property + def ozone(self): + """Return the ozone level.""" + return None + + @property + def attribution(self): + """Return the attribution.""" + return None + + @property + def state_attributes(self): + """Return the state attributes.""" + data = { + ATTR_WEATHER_TEMPERATURE: + convert_temperature( + self.temperature, self.temperature_unit, + self.hass.config.units.temperature_unit), + ATTR_WEATHER_HUMIDITY: self.humidity, + } + + ozone = self.ozone + if ozone is not None: + data[ATTR_WEATHER_OZONE] = ozone + + pressure = self.pressure + if pressure is not None: + data[ATTR_WEATHER_PRESSURE] = pressure + + wind_bearing = self.wind_bearing + if wind_bearing is not None: + data[ATTR_WEATHER_WIND_BEARING] = wind_bearing + + wind_speed = self.wind_speed + if wind_speed is not None: + data[ATTR_WEATHER_WIND_SPEED] = wind_speed + + attribution = self.attribution + if attribution is not None: + data[ATTR_WEATHER_ATTRIBUTION] = attribution + + return data + + @property + def state(self): + """Return the current state.""" + return self.condition + + @property + def condition(self): + """Return the current condition.""" + raise NotImplementedError() + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return None diff --git a/homeassistant/components/weather/demo.py b/homeassistant/components/weather/demo.py new file mode 100644 index 00000000000..dec4dcf2450 --- /dev/null +++ b/homeassistant/components/weather/demo.py @@ -0,0 +1,95 @@ +""" +Demo platform that offers fake meteorological data. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/demo/ +""" +from homeassistant.components.weather import WeatherEntity +from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT) + +CONDITION_CLASSES = { + 'cloudy': [], + 'fog': [], + 'hail': [], + 'lightning': [], + 'lightning-rainy': [], + 'partlycloudy': [], + 'pouring': [], + 'rainy': ['shower rain'], + 'snowy': [], + 'snowy-rainy': [], + 'sunny': ['sunshine'], + 'windy': [], + 'windy-variant': [], + 'exceptional': [], +} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the Demo weather.""" + add_devices([ + DemoWeather('South', 'Sunshine', 21, 92, 1099, 0.5, TEMP_CELSIUS), + DemoWeather('North', 'Shower rain', -12, 54, 987, 4.8, TEMP_FAHRENHEIT) + ]) + + +# pylint: disable=too-many-arguments +class DemoWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, name, condition, temperature, humidity, pressure, + wind_speed, temperature_unit): + """Initialize the Demo weather.""" + self._name = name + self._condition = condition + self._temperature = temperature + self._temperature_unit = temperature_unit + self._humidity = humidity + self._pressure = pressure + self._wind_speed = wind_speed + + @property + def name(self): + """Return the name of the sensor.""" + return '{} {}'.format('Demo Weather', self._name) + + @property + def should_poll(self): + """No polling needed for a demo weather condition.""" + return False + + @property + def temperature(self): + """Return the temperature.""" + return self._temperature + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def humidity(self): + """Return the humidity.""" + return self._humidity + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._wind_speed + + @property + def pressure(self): + """Return the wind speed.""" + return self._pressure + + @property + def condition(self): + """Return the weather condition.""" + return [k for k, v in CONDITION_CLASSES.items() if + self._condition.lower() in v][0] + + @property + def attribution(self): + """Return the attribution.""" + return 'Powered by Home Assistant' diff --git a/homeassistant/components/weather/openweathermap.py b/homeassistant/components/weather/openweathermap.py new file mode 100644 index 00000000000..4133509de89 --- /dev/null +++ b/homeassistant/components/weather/openweathermap.py @@ -0,0 +1,159 @@ +""" +Support for the OpenWeatherMap (OWM) service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/weather.openweathermap/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.weather import WeatherEntity, PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, STATE_UNKNOWN) +import homeassistant.helpers.config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['pyowm==2.5.0'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = 'OpenWeatherMap' +ATTRIBUTION = 'Data provided by OpenWeatherMap' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + +CONDITION_CLASSES = { + 'cloudy': [804], + 'fog': [701, 741], + 'hail': [906], + 'lightning': [210, 211, 212, 221], + 'lightning-rainy': [200, 201, 202, 230, 231, 232], + 'partlycloudy': [801, 802, 803], + 'pouring': [504, 314, 502, 503, 522], + 'rainy': [300, 301, 302, 310, 311, 312, 313, 500, 501, 520, 521], + 'snowy': [600, 601, 602, 611, 612, 620, 621, 622], + 'snowy-rainy': [511, 615, 616], + 'sunny': [800], + 'windy': [905, 951, 952, 953, 954, 955, 956, 957], + 'windy-variant': [958, 959, 960, 961], + 'exceptional': [711, 721, 731, 751, 761, 762, 771, 900, 901, 962, 903, + 904], +} + +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_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the OpenWeatherMap weather platform.""" + import pyowm + + longitude = config.get(CONF_LONGITUDE, round(hass.config.longitude, 5)) + latitude = config.get(CONF_LATITUDE, round(hass.config.latitude, 5)) + name = config.get(CONF_NAME) + + try: + owm = pyowm.OWM(config.get(CONF_API_KEY)) + except pyowm.exceptions.api_call_error.APICallError: + _LOGGER.error("Error while connecting to OpenWeatherMap") + return False + + data = WeatherData(owm, latitude, longitude) + + add_devices([OpenWeatherMapWeather( + name, data, hass.config.units.temperature_unit)]) + + +# pylint: disable=too-few-public-methods +class OpenWeatherMapWeather(WeatherEntity): + """Implementation of an OpenWeatherMap sensor.""" + + def __init__(self, name, owm, temperature_unit): + """Initialize the sensor.""" + self._name = name + self._owm = owm + self._temperature_unit = temperature_unit + self.date = None + self.update() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def condition(self): + """Return the current condition.""" + try: + return [k for k, v in CONDITION_CLASSES.items() if + self.data.get_weather_code() in v][0] + except IndexError: + return STATE_UNKNOWN + + @property + def temperature(self): + """Return the temperature.""" + return self.data.get_temperature('celsius')['temp'] + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._temperature_unit + + @property + def pressure(self): + """Return the pressure.""" + return self.data.get_pressure()['press'] + + @property + def humidity(self): + """Return the humidity.""" + return self.data.get_humidity() + + @property + def wind_speed(self): + """Return the wind speed.""" + return self.data.get_wind()['speed'] + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self.data.get_wind()['deg'] + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + # pylint: disable=too-many-branches + def update(self): + """Get the latest data from OWM and updates the states.""" + self._owm.update() + self.data = self._owm.data + + +class WeatherData(object): + """Get the latest data from OpenWeatherMap.""" + + def __init__(self, owm, latitude, longitude): + """Initialize the data object.""" + self.owm = owm + self.latitude = latitude + self.longitude = longitude + self.data = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from OpenWeatherMap.""" + obs = self.owm.weather_at_coords(self.latitude, self.longitude) + if obs is None: + _LOGGER.warning("Failed to fetch data from OWM") + return + + self.data = obs.get_weather() diff --git a/requirements_all.txt b/requirements_all.txt index fd454ba809e..5ec6876efaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -384,6 +384,7 @@ pynetio==0.1.6 pynx584==0.2 # homeassistant.components.sensor.openweathermap +# homeassistant.components.weather.openweathermap pyowm==2.5.0 # homeassistant.components.switch.acer_projector diff --git a/tests/components/weather/__init__.py b/tests/components/weather/__init__.py new file mode 100644 index 00000000000..24df7abb1f3 --- /dev/null +++ b/tests/components/weather/__init__.py @@ -0,0 +1 @@ +"""The tests for Weather platforms.""" diff --git a/tests/components/weather/test_weather.py b/tests/components/weather/test_weather.py new file mode 100644 index 00000000000..97aaf0f6486 --- /dev/null +++ b/tests/components/weather/test_weather.py @@ -0,0 +1,57 @@ +"""The tests for the Weather component.""" +import unittest + +from homeassistant.components import weather +from homeassistant.components.weather import ( + ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_SPEED) +from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.bootstrap import setup_component + +from tests.common import get_test_home_assistant + + +class TestWeather(unittest.TestCase): + """Test the 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.assertTrue(setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'platform': 'demo', + } + })) + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + def test_attributes(self): + """Test weather attributes.""" + state = self.hass.states.get('weather.demo_weather_south') + assert state is not None + + assert state.state == 'sunny' + + data = state.attributes + assert data.get(ATTR_WEATHER_TEMPERATURE) == 21 + assert data.get(ATTR_WEATHER_HUMIDITY) == 92 + assert data.get(ATTR_WEATHER_PRESSURE) == 1099 + assert data.get(ATTR_WEATHER_WIND_SPEED) == 0.5 + assert data.get(ATTR_WEATHER_WIND_BEARING) is None + assert data.get(ATTR_WEATHER_OZONE) is None + assert data.get(ATTR_WEATHER_ATTRIBUTION) == \ + 'Powered by Home Assistant' + + def test_temperature_convert(self): + """Test temperature conversion.""" + state = self.hass.states.get('weather.demo_weather_north') + assert state is not None + + assert state.state == 'rainy' + + data = state.attributes + assert data.get(ATTR_WEATHER_TEMPERATURE) == -24.4