From fcfbdd2d89fc4225a0c661989cbc536341666619 Mon Sep 17 00:00:00 2001 From: michaeldavie Date: Thu, 6 Jun 2019 14:47:27 -0400 Subject: [PATCH] Add Environment Canada weather, sensor, and camera platforms (#21110) * Added Environment Canada weather platform * Added Environment Canada weather platform * Migrate to new folder structure * Migrate to new folder structure * Fix updates * Fix updates again * Bump env_canada to 0.0.4 * Bump env_canada to 0.0.4 * Bump env_canada to 0.0.4 in requirements_all.txt * Change daily forecast timestamp and high/low test * Change daily forecast timestamp and high/low test * Bump env_canada to 0.0.5 * Break alerts into multiple sensors, bump env_canada to 0.0.6 * Bump env_canada to 0.0.7 * Remove blank line * Remove 'ec' sensor prefix, bump env_canada to 0.0.8 * Corrections * Change to manifests.json * Add docstring to __init.py__ * Update CODEOWNERS * pylint correction * pylint correction * Add alert details, bump env_canada to 0.0.9 * Update requirements_all.txt * Update .coveragerc * Bump env_canada to 0.0.10 * Update requirements_all.txt --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/environment_canada/__init__.py | 1 + .../components/environment_canada/camera.py | 101 ++++++++ .../environment_canada/manifest.json | 12 + .../components/environment_canada/sensor.py | 178 ++++++++++++++ .../components/environment_canada/weather.py | 219 ++++++++++++++++++ requirements_all.txt | 3 + 8 files changed, 516 insertions(+) create mode 100644 homeassistant/components/environment_canada/__init__.py create mode 100755 homeassistant/components/environment_canada/camera.py create mode 100644 homeassistant/components/environment_canada/manifest.json create mode 100755 homeassistant/components/environment_canada/sensor.py create mode 100644 homeassistant/components/environment_canada/weather.py diff --git a/.coveragerc b/.coveragerc index e329e2f31b9..8cde0c194fd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -165,6 +165,7 @@ omit = homeassistant/components/enocean/* homeassistant/components/enphase_envoy/sensor.py homeassistant/components/entur_public_transport/* + homeassistant/components/environment_canada/* homeassistant/components/envirophat/sensor.py homeassistant/components/envisalink/* homeassistant/components/ephember/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 2dc7d5f3701..97aad6177d3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -72,6 +72,7 @@ homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/emby/* @mezz64 homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer +homeassistant/components/environment_canada/* @michaeldavie homeassistant/components/ephember/* @ttroy50 homeassistant/components/epsonworkforce/* @ThaStealth homeassistant/components/eq3btsmart/* @rytilahti diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py new file mode 100644 index 00000000000..356e18fe23f --- /dev/null +++ b/homeassistant/components/environment_canada/__init__.py @@ -0,0 +1 @@ +"""A component for Environment Canada weather.""" diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py new file mode 100755 index 00000000000..18a88129e1d --- /dev/null +++ b/homeassistant/components/environment_canada/camera.py @@ -0,0 +1,101 @@ +""" +Support for the Environment Canada radar imagery. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.environment_canada/ +""" +import datetime +import logging + +import voluptuous as vol + +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, Camera) +from homeassistant.const import ( + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, ATTR_ATTRIBUTION) +from homeassistant.util import Throttle +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATION = 'station' +ATTR_LOCATION = 'location' + +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = 'station' +CONF_LOOP = 'loop' +CONF_PRECIP_TYPE = 'precip_type' + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_LOOP, default=True): cv.boolean, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): cv.string, + vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude, + vol.Optional(CONF_PRECIP_TYPE): ['RAIN', 'SNOW'], +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada camera.""" + from env_canada import ECRadar + + if config.get(CONF_STATION): + radar_object = ECRadar(station_id=config[CONF_STATION], + precip_type=config.get(CONF_PRECIP_TYPE)) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + radar_object = ECRadar(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE]), + precip_type=config.get(CONF_PRECIP_TYPE)) + else: + radar_object = ECRadar(coordinates=(hass.config.latitude, + hass.config.longitude), + precip_type=config.get(CONF_PRECIP_TYPE)) + + add_devices([ECCamera(radar_object, config.get(CONF_NAME))], True) + + +class ECCamera(Camera): + """Implementation of an Environment Canada radar camera.""" + + def __init__(self, radar_object, camera_name): + """Initialize the camera.""" + super().__init__() + + self.radar_object = radar_object + self.camera_name = camera_name + self.content_type = 'image/gif' + self.image = None + + def camera_image(self): + """Return bytes of camera image.""" + self.update() + return self.image + + @property + def name(self): + """Return the name of the camera.""" + if self.camera_name is not None: + return self.camera_name + return ' '.join([self.radar_object.station_name, 'Radar']) + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_LOCATION: self.radar_object.station_name, + ATTR_STATION: self.radar_object.station_code + } + + return attr + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update radar image.""" + if CONF_LOOP: + self.image = self.radar_object.get_loop() + else: + self.image = self.radar_object.get_latest_frame() diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json new file mode 100644 index 00000000000..ea809238499 --- /dev/null +++ b/homeassistant/components/environment_canada/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "environment_canada", + "name": "Environment Canada", + "documentation": "https://www.home-assistant.io/components/environment_canada", + "requirements": [ + "env_canada==0.0.10" + ], + "dependencies": [], + "codeowners": [ + "@michaeldavie" + ] +} diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py new file mode 100755 index 00000000000..c0b78cd4f35 --- /dev/null +++ b/homeassistant/components/environment_canada/sensor.py @@ -0,0 +1,178 @@ +""" +Support for the Environment Canada weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.environment_canada/ +""" +import datetime +import logging +import re + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, CONF_NAME, CONF_LATITUDE, + CONF_LONGITUDE, ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_HIDDEN) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +import homeassistant.util.dt as dt +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_UPDATED = 'updated' +ATTR_STATION = 'station' +ATTR_DETAIL = 'alert detail' +ATTR_TIME = 'alert time' + +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = 'station' + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + +SENSOR_TYPES = { + 'temperature': {'name': 'Temperature', + 'unit': TEMP_CELSIUS}, + 'dewpoint': {'name': 'Dew Point', + 'unit': TEMP_CELSIUS}, + 'wind_chill': {'name': 'Wind Chill', + 'unit': TEMP_CELSIUS}, + 'humidex': {'name': 'Humidex', + 'unit': TEMP_CELSIUS}, + 'pressure': {'name': 'Pressure', + 'unit': 'kPa'}, + 'tendency': {'name': 'Tendency'}, + 'humidity': {'name': 'Humidity', + 'unit': '%'}, + 'visibility': {'name': 'Visibility', + 'unit': 'km'}, + 'condition': {'name': 'Condition'}, + 'wind_speed': {'name': 'Wind Speed', + 'unit': 'km/h'}, + 'wind_gust': {'name': 'Wind Gust', + 'unit': 'km/h'}, + 'wind_dir': {'name': 'Wind Direction'}, + 'high_temp': {'name': 'High Temperature', + 'unit': TEMP_CELSIUS}, + 'low_temp': {'name': 'Low Temperature', + 'unit': TEMP_CELSIUS}, + 'pop': {'name': 'Chance of Precip.', + 'unit': '%'}, + 'warnings': {'name': 'Warnings'}, + 'watches': {'name': 'Watches'}, + 'advisories': {'name': 'Advisories'}, + 'statements': {'name': 'Statements'}, + 'endings': {'name': 'Ended'} +} + + +def validate_station(station): + """Check that the station ID is well-formed.""" + if station is None: + return + if not re.fullmatch(r'[A-Z]{2}/s0000\d{3}', station): + raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"') + return station + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): validate_station, + vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada sensor.""" + from env_canada import ECData + + if config.get(CONF_STATION): + ec_data = ECData(station_id=config[CONF_STATION]) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + ec_data = ECData(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE])) + else: + ec_data = ECData(coordinates=(hass.config.latitude, + hass.config.longitude)) + + add_devices([ECSensor(sensor_type, ec_data, config.get(CONF_NAME)) + for sensor_type in config[CONF_MONITORED_CONDITIONS]], + True) + + +class ECSensor(Entity): + """Implementation of an Environment Canada sensor.""" + + def __init__(self, sensor_type, ec_data, platform_name): + """Initialize the sensor.""" + self.sensor_type = sensor_type + self.ec_data = ec_data + self.platform_name = platform_name + self._state = None + self._attr = None + + @property + def name(self): + """Return the name of the sensor.""" + if self.platform_name is None: + return SENSOR_TYPES[self.sensor_type]['name'] + + return ' '.join([self.platform_name, + SENSOR_TYPES[self.sensor_type]['name']]) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes of the device.""" + return self._attr + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return SENSOR_TYPES[self.sensor_type].get('unit') + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update current conditions.""" + self.ec_data.update() + self.ec_data.conditions.update(self.ec_data.alerts) + + self._attr = {} + + sensor_data = self.ec_data.conditions.get(self.sensor_type) + if isinstance(sensor_data, list): + self._state = ' | '.join([str(s.get('title')) + for s in sensor_data]) + self._attr.update({ + ATTR_DETAIL: ' | '.join([str(s.get('detail')) + for s in sensor_data]), + ATTR_TIME: ' | '.join([str(s.get('date')) + for s in sensor_data]) + }) + else: + self._state = sensor_data + + timestamp = self.ec_data.conditions.get('timestamp') + if timestamp: + updated_utc = datetime.datetime.strptime(timestamp, '%Y%m%d%H%M%S') + updated_local = dt.as_local(updated_utc).isoformat() + else: + updated_local = None + + hidden = bool(self._state is None or self._state == '') + + self._attr.update({ + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_UPDATED: updated_local, + ATTR_LOCATION: self.ec_data.conditions.get('location'), + ATTR_STATION: self.ec_data.conditions.get('station'), + ATTR_HIDDEN: hidden + }) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py new file mode 100644 index 00000000000..0589a23445e --- /dev/null +++ b/homeassistant/components/environment_canada/weather.py @@ -0,0 +1,219 @@ +""" +Platform for retrieving meteorological data from Environment Canada. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/weather.environmentcanada/ +""" +import datetime +import logging +import re + +from env_canada import ECData +import voluptuous as vol + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) +from homeassistant.const import ( + CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS) +from homeassistant.util import Throttle +import homeassistant.util.dt as dt +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FORECAST = 'forecast' +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_STATION = 'station' + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) + + +def validate_station(station): + """Check that the station ID is well-formed.""" + if station is None: + return + if not re.fullmatch(r'[A-Z]{2}/s0000\d{3}', station): + raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"') + return station + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): validate_station, + vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude, + vol.Optional(CONF_FORECAST, default='daily'): + vol.In(['daily', 'hourly']), +}) + +# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/ +# docs/current_conditions_icon_code_descriptions_e.csv +ICON_CONDITION_MAP = {'sunny': [0, 1], + 'clear-night': [30, 31], + 'partlycloudy': [2, 3, 4, 5, 22, 32, 33, 34, 35], + 'cloudy': [10], + 'rainy': [6, 9, 11, 12, 28, 36], + 'lightning-rainy': [19, 39, 46, 47], + 'pouring': [13], + 'snowy-rainy': [7, 14, 15, 27, 37], + 'snowy': [8, 16, 17, 18, 25, 26, 38, 40], + 'windy': [43], + 'fog': [20, 21, 23, 24, 44], + 'hail': [26, 27]} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Environment Canada weather.""" + if config.get(CONF_STATION): + ec_data = ECData(station_id=config[CONF_STATION]) + elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE): + ec_data = ECData(coordinates=(config[CONF_LATITUDE], + config[CONF_LONGITUDE])) + else: + ec_data = ECData(coordinates=(hass.config.latitude, + hass.config.longitude)) + + add_devices([ECWeather(ec_data, config)]) + + +class ECWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, ec_data, config): + """Initialize Environment Canada weather.""" + self.ec_data = ec_data + self.platform_name = config.get(CONF_NAME) + self.forecast_type = config[CONF_FORECAST] + + @property + def attribution(self): + """Return the attribution.""" + return CONF_ATTRIBUTION + + @property + def name(self): + """Return the name of the weather entity.""" + if self.platform_name: + return self.platform_name + return self.ec_data.conditions['location'] + + @property + def temperature(self): + """Return the temperature.""" + if self.ec_data.conditions.get('temperature'): + return float(self.ec_data.conditions['temperature']) + return None + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def humidity(self): + """Return the humidity.""" + if self.ec_data.conditions.get('humidity'): + return float(self.ec_data.conditions['humidity']) + return None + + @property + def wind_speed(self): + """Return the wind speed.""" + if self.ec_data.conditions.get('wind_speed'): + return float(self.ec_data.conditions['wind_speed']) + return None + + @property + def wind_bearing(self): + """Return the wind bearing.""" + if self.ec_data.conditions.get('wind_bearing'): + return float(self.ec_data.conditions['wind_bearing']) + return None + + @property + def pressure(self): + """Return the pressure.""" + if self.ec_data.conditions.get('pressure'): + return 10 * float(self.ec_data.conditions['pressure']) + return None + + @property + def visibility(self): + """Return the visibility.""" + if self.ec_data.conditions.get('visibility'): + return float(self.ec_data.conditions['visibility']) + return None + + @property + def condition(self): + """Return the weather condition.""" + icon_code = self.ec_data.conditions.get('icon_code') + if icon_code: + return icon_code_to_condition(int(icon_code)) + condition = self.ec_data.conditions.get('condition') + if condition: + return condition + return 'Condition not observed' + + @property + def forecast(self): + """Return the forecast array.""" + return get_forecast(self.ec_data, self.forecast_type) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data from Environment Canada.""" + self.ec_data.update() + + +def get_forecast(ec_data, forecast_type): + """Build the forecast array.""" + forecast_array = [] + + if forecast_type == 'daily': + half_days = ec_data.daily_forecasts + if half_days[0]['temperature_class'] == 'high': + forecast_array.append({ + ATTR_FORECAST_TIME: dt.now().isoformat(), + ATTR_FORECAST_TEMP: int(half_days[0]['temperature']), + ATTR_FORECAST_TEMP_LOW: int(half_days[1]['temperature']), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(half_days[0]['icon_code'])) + }) + half_days = half_days[2:] + else: + half_days = half_days[1:] + + for day, high, low in zip(range(1, 6), + range(0, 9, 2), + range(1, 10, 2)): + forecast_array.append({ + ATTR_FORECAST_TIME: + (dt.now() + datetime.timedelta(days=day)).isoformat(), + ATTR_FORECAST_TEMP: int(half_days[high]['temperature']), + ATTR_FORECAST_TEMP_LOW: int(half_days[low]['temperature']), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(half_days[high]['icon_code'])) + }) + + elif forecast_type == 'hourly': + hours = ec_data.hourly_forecasts + for hour in range(0, 24): + forecast_array.append({ + ATTR_FORECAST_TIME: dt.as_local(datetime.datetime.strptime( + hours[hour]['period'], '%Y%m%d%H%M')).isoformat(), + ATTR_FORECAST_TEMP: int(hours[hour]['temperature']), + ATTR_FORECAST_CONDITION: icon_code_to_condition( + int(hours[hour]['icon_code'])) + }) + + return forecast_array + + +def icon_code_to_condition(icon_code): + """Return the condition corresponding to an icon code.""" + for condition, codes in ICON_CONDITION_MAP.items(): + if icon_code in codes: + return condition + return None diff --git a/requirements_all.txt b/requirements_all.txt index aeb768221fd..7106f03ea43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -411,6 +411,9 @@ enocean==0.50 # homeassistant.components.entur_public_transport enturclient==0.2.0 +# homeassistant.components.environment_canada +env_canada==0.0.10 + # homeassistant.components.envirophat # envirophat==0.0.6