"""Support for Buienradar.nl weather service.""" import logging from buienradar.constants import ( ATTRIBUTION, CONDCODE, CONDITION, DETAILED, EXACT, EXACTNL, FORECAST, IMAGE, MEASURED, PRECIPITATION_FORECAST, STATIONNAME, TIMEFRAME, VISIBILITY, WINDGUST, WINDSPEED, ) import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, DEGREE, IRRADIATION_WATTS_PER_SQUARE_METER, LENGTH_KILOMETERS, LENGTH_MILLIMETERS, PERCENTAGE, PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, TIME_HOURS, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import dt as dt_util from .const import DEFAULT_TIMEFRAME from .util import BrData _LOGGER = logging.getLogger(__name__) MEASURED_LABEL = "Measured" TIMEFRAME_LABEL = "Timeframe" SYMBOL = "symbol" # Schedule next call after (minutes): SCHEDULE_OK = 10 # When an error occurred, new call after (minutes): SCHEDULE_NOK = 2 # Supported sensor types: # Key: ['label', unit, icon] SENSOR_TYPES = { "stationname": ["Stationname", None, None], # new in json api (>1.0.0): "barometerfc": ["Barometer value", None, "mdi:gauge"], # new in json api (>1.0.0): "barometerfcname": ["Barometer", None, "mdi:gauge"], # new in json api (>1.0.0): "barometerfcnamenl": ["Barometer", None, "mdi:gauge"], "condition": ["Condition", None, None], "conditioncode": ["Condition code", None, None], "conditiondetailed": ["Detailed condition", None, None], "conditionexact": ["Full condition", None, None], "symbol": ["Symbol", None, None], # new in json api (>1.0.0): "feeltemperature": ["Feel temperature", TEMP_CELSIUS, "mdi:thermometer"], "humidity": ["Humidity", PERCENTAGE, "mdi:water-percent"], "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "windforce": ["Wind force", "Bft", "mdi:weather-windy"], "winddirection": ["Wind direction", None, "mdi:compass-outline"], "windazimuth": ["Wind direction azimuth", DEGREE, "mdi:compass-outline"], "pressure": ["Pressure", PRESSURE_HPA, "mdi:gauge"], "visibility": ["Visibility", LENGTH_KILOMETERS, None], "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "precipitation": [ "Precipitation", f"{LENGTH_MILLIMETERS}/{TIME_HOURS}", "mdi:weather-pouring", ], "irradiance": ["Irradiance", IRRADIATION_WATTS_PER_SQUARE_METER, "mdi:sunglasses"], "precipitation_forecast_average": [ "Precipitation forecast average", f"{LENGTH_MILLIMETERS}/{TIME_HOURS}", "mdi:weather-pouring", ], "precipitation_forecast_total": [ "Precipitation forecast total", LENGTH_MILLIMETERS, "mdi:weather-pouring", ], # new in json api (>1.0.0): "rainlast24hour": ["Rain last 24h", LENGTH_MILLIMETERS, "mdi:weather-pouring"], # new in json api (>1.0.0): "rainlasthour": ["Rain last hour", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "temperature_1d": ["Temperature 1d", TEMP_CELSIUS, "mdi:thermometer"], "temperature_2d": ["Temperature 2d", TEMP_CELSIUS, "mdi:thermometer"], "temperature_3d": ["Temperature 3d", TEMP_CELSIUS, "mdi:thermometer"], "temperature_4d": ["Temperature 4d", TEMP_CELSIUS, "mdi:thermometer"], "temperature_5d": ["Temperature 5d", TEMP_CELSIUS, "mdi:thermometer"], "mintemp_1d": ["Minimum temperature 1d", TEMP_CELSIUS, "mdi:thermometer"], "mintemp_2d": ["Minimum temperature 2d", TEMP_CELSIUS, "mdi:thermometer"], "mintemp_3d": ["Minimum temperature 3d", TEMP_CELSIUS, "mdi:thermometer"], "mintemp_4d": ["Minimum temperature 4d", TEMP_CELSIUS, "mdi:thermometer"], "mintemp_5d": ["Minimum temperature 5d", TEMP_CELSIUS, "mdi:thermometer"], "rain_1d": ["Rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "rain_2d": ["Rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "rain_3d": ["Rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "rain_4d": ["Rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "rain_5d": ["Rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], # new in json api (>1.0.0): "minrain_1d": ["Minimum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "minrain_2d": ["Minimum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "minrain_3d": ["Minimum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "minrain_4d": ["Minimum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "minrain_5d": ["Minimum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], # new in json api (>1.0.0): "maxrain_1d": ["Maximum rain 1d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "maxrain_2d": ["Maximum rain 2d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "maxrain_3d": ["Maximum rain 3d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "maxrain_4d": ["Maximum rain 4d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "maxrain_5d": ["Maximum rain 5d", LENGTH_MILLIMETERS, "mdi:weather-pouring"], "rainchance_1d": ["Rainchance 1d", PERCENTAGE, "mdi:weather-pouring"], "rainchance_2d": ["Rainchance 2d", PERCENTAGE, "mdi:weather-pouring"], "rainchance_3d": ["Rainchance 3d", PERCENTAGE, "mdi:weather-pouring"], "rainchance_4d": ["Rainchance 4d", PERCENTAGE, "mdi:weather-pouring"], "rainchance_5d": ["Rainchance 5d", PERCENTAGE, "mdi:weather-pouring"], "sunchance_1d": ["Sunchance 1d", PERCENTAGE, "mdi:weather-partly-cloudy"], "sunchance_2d": ["Sunchance 2d", PERCENTAGE, "mdi:weather-partly-cloudy"], "sunchance_3d": ["Sunchance 3d", PERCENTAGE, "mdi:weather-partly-cloudy"], "sunchance_4d": ["Sunchance 4d", PERCENTAGE, "mdi:weather-partly-cloudy"], "sunchance_5d": ["Sunchance 5d", PERCENTAGE, "mdi:weather-partly-cloudy"], "windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy"], "windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy"], "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"], "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy"], "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy"], "windspeed_1d": ["Wind speed 1d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "windspeed_2d": ["Wind speed 2d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "windspeed_3d": ["Wind speed 3d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "windspeed_4d": ["Wind speed 4d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "windspeed_5d": ["Wind speed 5d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"], "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"], "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"], "winddirection_4d": ["Wind direction 4d", None, "mdi:compass-outline"], "winddirection_5d": ["Wind direction 5d", None, "mdi:compass-outline"], "windazimuth_1d": ["Wind direction azimuth 1d", DEGREE, "mdi:compass-outline"], "windazimuth_2d": ["Wind direction azimuth 2d", DEGREE, "mdi:compass-outline"], "windazimuth_3d": ["Wind direction azimuth 3d", DEGREE, "mdi:compass-outline"], "windazimuth_4d": ["Wind direction azimuth 4d", DEGREE, "mdi:compass-outline"], "windazimuth_5d": ["Wind direction azimuth 5d", DEGREE, "mdi:compass-outline"], "condition_1d": ["Condition 1d", None, None], "condition_2d": ["Condition 2d", None, None], "condition_3d": ["Condition 3d", None, None], "condition_4d": ["Condition 4d", None, None], "condition_5d": ["Condition 5d", None, None], "conditioncode_1d": ["Condition code 1d", None, None], "conditioncode_2d": ["Condition code 2d", None, None], "conditioncode_3d": ["Condition code 3d", None, None], "conditioncode_4d": ["Condition code 4d", None, None], "conditioncode_5d": ["Condition code 5d", None, None], "conditiondetailed_1d": ["Detailed condition 1d", None, None], "conditiondetailed_2d": ["Detailed condition 2d", None, None], "conditiondetailed_3d": ["Detailed condition 3d", None, None], "conditiondetailed_4d": ["Detailed condition 4d", None, None], "conditiondetailed_5d": ["Detailed condition 5d", None, None], "conditionexact_1d": ["Full condition 1d", None, None], "conditionexact_2d": ["Full condition 2d", None, None], "conditionexact_3d": ["Full condition 3d", None, None], "conditionexact_4d": ["Full condition 4d", None, None], "conditionexact_5d": ["Full condition 5d", None, None], "symbol_1d": ["Symbol 1d", None, None], "symbol_2d": ["Symbol 2d", None, None], "symbol_3d": ["Symbol 3d", None, None], "symbol_4d": ["Symbol 4d", None, None], "symbol_5d": ["Symbol 5d", None, None], } CONF_TIMEFRAME = "timeframe" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional( CONF_MONITORED_CONDITIONS, default=["symbol", "temperature"] ): vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES.keys())]), 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_TIMEFRAME, default=DEFAULT_TIMEFRAME): vol.All( vol.Coerce(int), vol.Range(min=5, max=120) ), vol.Optional(CONF_NAME, default="br"): cv.string, } ) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Create the buienradar sensor.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) timeframe = config[CONF_TIMEFRAME] if None in (latitude, longitude): _LOGGER.error("Latitude or longitude not set in Home Assistant config") return False coordinates = {CONF_LATITUDE: float(latitude), CONF_LONGITUDE: float(longitude)} _LOGGER.debug( "Initializing buienradar sensor coordinate %s, timeframe %s", coordinates, timeframe, ) dev = [] for sensor_type in config[CONF_MONITORED_CONDITIONS]: dev.append(BrSensor(sensor_type, config.get(CONF_NAME), coordinates)) async_add_entities(dev) data = BrData(hass, coordinates, timeframe, dev) # schedule the first update in 1 minute from now: await data.schedule_update(1) class BrSensor(Entity): """Representation of an Buienradar sensor.""" def __init__(self, sensor_type, client_name, coordinates): """Initialize the sensor.""" self.client_name = client_name self._name = SENSOR_TYPES[sensor_type][0] self.type = sensor_type self._state = None self._unit_of_measurement = SENSOR_TYPES[self.type][1] self._entity_picture = None self._attribution = None self._measured = None self._stationname = None self._unique_id = self.uid(coordinates) # All continuous sensors should be forced to be updated self._force_update = self.type != SYMBOL and not self.type.startswith(CONDITION) if self.type.startswith(PRECIPITATION_FORECAST): self._timeframe = None def uid(self, coordinates): """Generate a unique id using coordinates and sensor type.""" # The combination of the location, name and sensor type is unique return "{:2.6f}{:2.6f}{}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], self.type ) @callback def data_updated(self, data): """Update data.""" if self._load_data(data) and self.hass: self.async_write_ha_state() @callback def _load_data(self, data): """Load the sensor with relevant data.""" # Find sensor # Check if we have a new measurement, # otherwise we do not have to update the sensor if self._measured == data.get(MEASURED): return False self._attribution = data.get(ATTRIBUTION) self._stationname = data.get(STATIONNAME) self._measured = data.get(MEASURED) if ( self.type.endswith("_1d") or self.type.endswith("_2d") or self.type.endswith("_3d") or self.type.endswith("_4d") or self.type.endswith("_5d") ): # update forcasting sensors: fcday = 0 if self.type.endswith("_2d"): fcday = 1 if self.type.endswith("_3d"): fcday = 2 if self.type.endswith("_4d"): fcday = 3 if self.type.endswith("_5d"): fcday = 4 # update weather symbol & status text if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION): try: condition = data.get(FORECAST)[fcday].get(CONDITION) except IndexError: _LOGGER.warning("No forecast for fcday=%s...", fcday) return False if condition: new_state = condition.get(CONDITION) if self.type.startswith(SYMBOL): new_state = condition.get(EXACTNL) if self.type.startswith("conditioncode"): new_state = condition.get(CONDCODE) if self.type.startswith("conditiondetailed"): new_state = condition.get(DETAILED) if self.type.startswith("conditionexact"): new_state = condition.get(EXACT) img = condition.get(IMAGE) if new_state != self._state or img != self._entity_picture: self._state = new_state self._entity_picture = img return True return False if self.type.startswith(WINDSPEED): # hass wants windspeeds in km/h not m/s, so convert: try: self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) if self._state is not None: self._state = round(self._state * 3.6, 1) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s...", fcday) return False # update all other sensors try: self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) return True except IndexError: _LOGGER.warning("No forecast for fcday=%s...", fcday) return False if self.type == SYMBOL or self.type.startswith(CONDITION): # update weather symbol & status text condition = data.get(CONDITION) if condition: if self.type == SYMBOL: new_state = condition.get(EXACTNL) if self.type == CONDITION: new_state = condition.get(CONDITION) if self.type == "conditioncode": new_state = condition.get(CONDCODE) if self.type == "conditiondetailed": new_state = condition.get(DETAILED) if self.type == "conditionexact": new_state = condition.get(EXACT) img = condition.get(IMAGE) if new_state != self._state or img != self._entity_picture: self._state = new_state self._entity_picture = img return True return False if self.type.startswith(PRECIPITATION_FORECAST): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) self._timeframe = nested.get(TIMEFRAME) self._state = nested.get(self.type[len(PRECIPITATION_FORECAST) + 1 :]) return True if self.type == WINDSPEED or self.type == WINDGUST: # hass wants windspeeds in km/h not m/s, so convert: self._state = data.get(self.type) if self._state is not None: self._state = round(data.get(self.type) * 3.6, 1) return True if self.type == VISIBILITY: # hass wants visibility in km (not m), so convert: self._state = data.get(self.type) if self._state is not None: self._state = round(self._state / 1000, 1) return True # update all other sensors self._state = data.get(self.type) return True @property def attribution(self): """Return the attribution.""" return self._attribution @property def unique_id(self): """Return the unique id.""" return self._unique_id @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 device.""" return self._state @property def should_poll(self): """No polling needed.""" return False @property def entity_picture(self): """Weather symbol if type is symbol.""" return self._entity_picture @property def device_state_attributes(self): """Return the state attributes.""" if self.type.startswith(PRECIPITATION_FORECAST): result = {ATTR_ATTRIBUTION: self._attribution} if self._timeframe is not None: result[TIMEFRAME_LABEL] = "%d min" % (self._timeframe) return result result = { ATTR_ATTRIBUTION: self._attribution, SENSOR_TYPES["stationname"][0]: self._stationname, } if self._measured is not None: # convert datetime (Europe/Amsterdam) into local datetime local_dt = dt_util.as_local(self._measured) result[MEASURED_LABEL] = local_dt.strftime("%c") return result @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement @property def icon(self): """Return possible sensor specific icon.""" return SENSOR_TYPES[self.type][2] @property def force_update(self): """Return true for continuous sensors, false for discrete sensors.""" return self._force_update