"""Support for Dark Sky weather service.""" from datetime import timedelta import logging import forecastio from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout import voluptuous as vol from homeassistant.components.sensor import ( DEVICE_CLASS_TEMPERATURE, PLATFORM_SCHEMA, SensorEntity, ) from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL, DEGREE, LENGTH_CENTIMETERS, LENGTH_KILOMETERS, PERCENTAGE, PRECIPITATION_MILLIMETERS_PER_HOUR, PRESSURE_MBAR, SPEED_KILOMETERS_PER_HOUR, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, UV_INDEX, ) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by Dark Sky" CONF_FORECAST = "forecast" CONF_HOURLY_FORECAST = "hourly_forecast" CONF_LANGUAGE = "language" CONF_UNITS = "units" DEFAULT_LANGUAGE = "en" DEFAULT_NAME = "Dark Sky" SCAN_INTERVAL = timedelta(seconds=300) DEPRECATED_SENSOR_TYPES = { "apparent_temperature_max", "apparent_temperature_min", "temperature_max", "temperature_min", } # Sensor types are defined like so: # Name, si unit, us unit, ca unit, uk unit, uk2 unit SENSOR_TYPES = { "summary": [ "Summary", None, None, None, None, None, None, ["currently", "hourly", "daily"], ], "minutely_summary": ["Minutely Summary", None, None, None, None, None, None, []], "hourly_summary": ["Hourly Summary", None, None, None, None, None, None, []], "daily_summary": ["Daily Summary", None, None, None, None, None, None, []], "icon": [ "Icon", None, None, None, None, None, None, ["currently", "hourly", "daily"], ], "nearest_storm_distance": [ "Nearest Storm Distance", LENGTH_KILOMETERS, "mi", LENGTH_KILOMETERS, LENGTH_KILOMETERS, "mi", "mdi:weather-lightning", ["currently"], ], "nearest_storm_bearing": [ "Nearest Storm Bearing", DEGREE, DEGREE, DEGREE, DEGREE, DEGREE, "mdi:weather-lightning", ["currently"], ], "precip_type": [ "Precip", None, None, None, None, None, "mdi:weather-pouring", ["currently", "minutely", "hourly", "daily"], ], "precip_intensity": [ "Precip Intensity", PRECIPITATION_MILLIMETERS_PER_HOUR, "in", PRECIPITATION_MILLIMETERS_PER_HOUR, PRECIPITATION_MILLIMETERS_PER_HOUR, PRECIPITATION_MILLIMETERS_PER_HOUR, "mdi:weather-rainy", ["currently", "minutely", "hourly", "daily"], ], "precip_probability": [ "Precip Probability", PERCENTAGE, PERCENTAGE, PERCENTAGE, PERCENTAGE, PERCENTAGE, "mdi:water-percent", ["currently", "minutely", "hourly", "daily"], ], "precip_accumulation": [ "Precip Accumulation", LENGTH_CENTIMETERS, "in", LENGTH_CENTIMETERS, LENGTH_CENTIMETERS, LENGTH_CENTIMETERS, "mdi:weather-snowy", ["hourly", "daily"], ], "temperature": [ "Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["currently", "hourly"], ], "apparent_temperature": [ "Apparent Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["currently", "hourly"], ], "dew_point": [ "Dew Point", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["currently", "hourly", "daily"], ], "wind_speed": [ "Wind Speed", SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, SPEED_MILES_PER_HOUR, "mdi:weather-windy", ["currently", "hourly", "daily"], ], "wind_bearing": [ "Wind Bearing", DEGREE, DEGREE, DEGREE, DEGREE, DEGREE, "mdi:compass", ["currently", "hourly", "daily"], ], "wind_gust": [ "Wind Gust", SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, SPEED_MILES_PER_HOUR, "mdi:weather-windy-variant", ["currently", "hourly", "daily"], ], "cloud_cover": [ "Cloud Coverage", PERCENTAGE, PERCENTAGE, PERCENTAGE, PERCENTAGE, PERCENTAGE, "mdi:weather-partly-cloudy", ["currently", "hourly", "daily"], ], "humidity": [ "Humidity", PERCENTAGE, PERCENTAGE, PERCENTAGE, PERCENTAGE, PERCENTAGE, "mdi:water-percent", ["currently", "hourly", "daily"], ], "pressure": [ "Pressure", PRESSURE_MBAR, PRESSURE_MBAR, PRESSURE_MBAR, PRESSURE_MBAR, PRESSURE_MBAR, "mdi:gauge", ["currently", "hourly", "daily"], ], "visibility": [ "Visibility", LENGTH_KILOMETERS, "mi", LENGTH_KILOMETERS, LENGTH_KILOMETERS, "mi", "mdi:eye", ["currently", "hourly", "daily"], ], "ozone": [ "Ozone", "DU", "DU", "DU", "DU", "DU", "mdi:eye", ["currently", "hourly", "daily"], ], "apparent_temperature_max": [ "Daily High Apparent Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["daily"], ], "apparent_temperature_high": [ "Daytime High Apparent Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["daily"], ], "apparent_temperature_min": [ "Daily Low Apparent Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["daily"], ], "apparent_temperature_low": [ "Overnight Low Apparent Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["daily"], ], "temperature_max": [ "Daily High Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["daily"], ], "temperature_high": [ "Daytime High Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["daily"], ], "temperature_min": [ "Daily Low Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["daily"], ], "temperature_low": [ "Overnight Low Temperature", TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_CELSIUS, TEMP_CELSIUS, "mdi:thermometer", ["daily"], ], "precip_intensity_max": [ "Daily Max Precip Intensity", PRECIPITATION_MILLIMETERS_PER_HOUR, "in", PRECIPITATION_MILLIMETERS_PER_HOUR, PRECIPITATION_MILLIMETERS_PER_HOUR, PRECIPITATION_MILLIMETERS_PER_HOUR, "mdi:thermometer", ["daily"], ], "uv_index": [ "UV Index", UV_INDEX, UV_INDEX, UV_INDEX, UV_INDEX, UV_INDEX, "mdi:weather-sunny", ["currently", "hourly", "daily"], ], "moon_phase": [ "Moon Phase", None, None, None, None, None, "mdi:weather-night", ["daily"], ], "sunrise_time": [ "Sunrise", None, None, None, None, None, "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 = { "clear-day": ["/static/images/darksky/weather-sunny.svg", "mdi:weather-sunny"], "clear-night": ["/static/images/darksky/weather-night.svg", "mdi:weather-night"], "rain": ["/static/images/darksky/weather-pouring.svg", "mdi:weather-pouring"], "snow": ["/static/images/darksky/weather-snowy.svg", "mdi:weather-snowy"], "sleet": ["/static/images/darksky/weather-hail.svg", "mdi:weather-snowy-rainy"], "wind": ["/static/images/darksky/weather-windy.svg", "mdi:weather-windy"], "fog": ["/static/images/darksky/weather-fog.svg", "mdi:weather-fog"], "cloudy": ["/static/images/darksky/weather-cloudy.svg", "mdi:weather-cloudy"], "partly-cloudy-day": [ "/static/images/darksky/weather-partlycloudy.svg", "mdi:weather-partly-cloudy", ], "partly-cloudy-night": [ "/static/images/darksky/weather-cloudy.svg", "mdi:weather-night-partly-cloudy", ], } # Language Supported Codes LANGUAGE_CODES = [ "ar", "az", "be", "bg", "bn", "bs", "ca", "cs", "da", "de", "el", "en", "ja", "ka", "kn", "ko", "eo", "es", "et", "fi", "fr", "he", "hi", "hr", "hu", "id", "is", "it", "kw", "lv", "ml", "mr", "nb", "nl", "pa", "pl", "pt", "ro", "ru", "sk", "sl", "sr", "sv", "ta", "te", "tet", "tr", "uk", "ur", "x-pig-latin", "zh", "zh-tw", ] 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)] ), vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNITS): vol.In(ALLOWED_UNITS), vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES), 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_FORECAST): vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]), vol.Optional(CONF_HOURLY_FORECAST): vol.All( cv.ensure_list, [vol.Range(min=0, max=48)] ), } ) def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Dark Sky sensor.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) language = config.get(CONF_LANGUAGE) interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) if CONF_UNITS in config: units = config[CONF_UNITS] elif hass.config.units.is_metric: units = "si" else: units = "us" forecast_data = DarkSkyData( api_key=config.get(CONF_API_KEY), latitude=latitude, longitude=longitude, units=units, language=language, interval=interval, ) forecast_data.update() forecast_data.update_currently() # If connection failed don't setup platform. if forecast_data.data is None: return name = config.get(CONF_NAME) forecast = config.get(CONF_FORECAST) forecast_hour = config.get(CONF_HOURLY_FORECAST) sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: if variable in DEPRECATED_SENSOR_TYPES: _LOGGER.warning("Monitored condition %s is deprecated", variable) if not SENSOR_TYPES[variable][7] or "currently" in SENSOR_TYPES[variable][7]: 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( forecast_data, variable, name, forecast_day=forecast_day ) ) if forecast_hour is not None and "hourly" in SENSOR_TYPES[variable][7]: for forecast_h in forecast_hour: sensors.append( DarkSkySensor( forecast_data, variable, name, forecast_hour=forecast_h ) ) add_entities(sensors, True) class DarkSkySensor(SensorEntity): """Implementation of a Dark Sky sensor.""" def __init__( self, forecast_data, sensor_type, name, forecast_day=None, forecast_hour=None ): """Initialize the sensor.""" self.client_name = name self._name = SENSOR_TYPES[sensor_type][0] self.forecast_data = forecast_data self.type = sensor_type self.forecast_day = forecast_day self.forecast_hour = forecast_hour self._state = None self._icon = None self._unit_of_measurement = None @property def name(self): """Return the name of the sensor.""" if self.forecast_day is not None: return f"{self.client_name} {self._name} {self.forecast_day}d" if self.forecast_hour is not None: return f"{self.client_name} {self._name} {self.forecast_hour}h" return f"{self.client_name} {self._name}" @property def state(self): """Return the state of the sensor.""" return self._state @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @property def unit_system(self): """Return the unit system of this entity.""" return self.forecast_data.unit_system @property def entity_picture(self): """Return the entity picture to use in the frontend, if any.""" if self._icon is None or "summary" not in self.type: return None if self._icon in CONDITION_PICTURES: return CONDITION_PICTURES[self._icon][0] return None def update_unit_of_measurement(self): """Update units based on unit system.""" unit_index = {"si": 1, "us": 2, "ca": 3, "uk": 4, "uk2": 5}.get( self.unit_system, 1 ) self._unit_of_measurement = SENSOR_TYPES[self.type][unit_index] @property def icon(self): """Icon to use in the frontend, if any.""" if "summary" in self.type and self._icon in CONDITION_PICTURES: return CONDITION_PICTURES[self._icon][1] return SENSOR_TYPES[self.type][6] @property def device_class(self): """Device class of the entity.""" if SENSOR_TYPES[self.type][1] == TEMP_CELSIUS: return DEVICE_CLASS_TEMPERATURE return None @property def extra_state_attributes(self): """Return the state attributes.""" return {ATTR_ATTRIBUTION: ATTRIBUTION} 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.update_unit_of_measurement() if self.type == "minutely_summary": self.forecast_data.update_minutely() minutely = self.forecast_data.data_minutely self._state = getattr(minutely, "summary", "") self._icon = getattr(minutely, "icon", "") elif self.type == "hourly_summary": self.forecast_data.update_hourly() hourly = self.forecast_data.data_hourly self._state = getattr(hourly, "summary", "") self._icon = getattr(hourly, "icon", "") elif self.forecast_hour is not None: self.forecast_data.update_hourly() hourly = self.forecast_data.data_hourly if hasattr(hourly, "data"): self._state = self.get_state(hourly.data[self.forecast_hour]) else: self._state = 0 elif self.type == "daily_summary": self.forecast_data.update_daily() daily = self.forecast_data.data_daily self._state = getattr(daily, "summary", "") self._icon = getattr(daily, "icon", "") elif self.forecast_day is not None: self.forecast_data.update_daily() daily = self.forecast_data.data_daily if hasattr(daily, "data"): self._state = self.get_state(daily.data[self.forecast_day]) else: self._state = 0 else: self.forecast_data.update_currently() currently = self.forecast_data.data_currently self._state = self.get_state(currently) def get_state(self, data): """ Return a new state based on the type. If the sensor type is unknown, the current state is returned. """ lookup_type = convert_to_camel(self.type) state = getattr(data, lookup_type, None) if state is None: return state if "summary" in self.type: self._icon = getattr(data, "icon", "") # Some state data needs to be rounded to whole values or converted to # percentages if self.type in ["precip_probability", "cloud_cover", "humidity"]: return round(state * 100, 1) if self.type in [ "dew_point", "temperature", "apparent_temperature", "temperature_low", "apparent_temperature_low", "temperature_min", "apparent_temperature_min", "temperature_high", "apparent_temperature_high", "temperature_max", "apparent_temperature_max", "precip_accumulation", "pressure", "ozone", "uvIndex", ]: return round(state, 1) return state class DarkSkyAlertSensor(SensorEntity): """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 f"{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 extra_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 = f"{attr}_{i!s}" 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). This is not pythonic, but needed for certain situations. """ components = data.split("_") capital_components = "".join(x.title() for x in components[1:]) return f"{components[0]}{capital_components}" class DarkSkyData: """Get the latest data from Darksky.""" def __init__(self, api_key, latitude, longitude, units, language, interval): """Initialize the data object.""" self._api_key = api_key self.latitude = latitude self.longitude = longitude self.units = units self.language = language self._connect_error = False self.data = None self.unit_system = None self.data_currently = None 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) self.update_currently = Throttle(interval)(self._update_currently) 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.""" try: self.data = forecastio.load_forecast( self._api_key, self.latitude, self.longitude, units=self.units, lang=self.language, ) if self._connect_error: self._connect_error = False _LOGGER.info("Reconnected to Dark Sky") except (ConnectError, HTTPError, Timeout, ValueError) as error: if not self._connect_error: self._connect_error = True _LOGGER.error("Unable to connect to Dark Sky: %s", error) self.data = None self.unit_system = self.data and self.data.json["flags"]["units"] def _update_currently(self): """Update currently data.""" self.data_currently = self.data and self.data.currently() def _update_minutely(self): """Update minutely data.""" self.data_minutely = self.data and self.data.minutely() def _update_hourly(self): """Update hourly data.""" self.data_hourly = self.data and self.data.hourly() 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()