diff --git a/.coveragerc b/.coveragerc index 5f9ccd9e01438522679404d0172e18bbf7118700..829915400bda41f8b703f12cf0697d98afb40325 100644 --- a/.coveragerc +++ b/.coveragerc @@ -269,7 +269,10 @@ omit = homeassistant/components/enphase_envoy/__init__.py homeassistant/components/enphase_envoy/sensor.py homeassistant/components/entur_public_transport/* - homeassistant/components/environment_canada/* + homeassistant/components/environment_canada/__init__.py + homeassistant/components/environment_canada/camera.py + homeassistant/components/environment_canada/sensor.py + homeassistant/components/environment_canada/weather.py homeassistant/components/envirophat/sensor.py homeassistant/components/envisalink/* homeassistant/components/ephember/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index a4756f961be0c8e30f96c47067575022a0125c48..e3a4cb00c83f4eb2f49a306427537a1ac7292be3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -151,7 +151,7 @@ homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer homeassistant/components/enphase_envoy/* @gtdiehl homeassistant/components/entur_public_transport/* @hfurubotten -homeassistant/components/environment_canada/* @michaeldavie +homeassistant/components/environment_canada/* @gwww @michaeldavie homeassistant/components/ephember/* @ttroy50 homeassistant/components/epson/* @pszafer homeassistant/components/epsonworkforce/* @ThaStealth diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 356e18fe23fd4dc9ef41e328ed105d08c920092a..0821059fcdfc08468fb30240c64858c59f93110c 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -1 +1,79 @@ -"""A component for Environment Canada weather.""" +"""The Environment Canada (EC) component.""" +from functools import partial +import logging + +from env_canada import ECData, ECRadar + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE + +from .const import CONF_LANGUAGE, CONF_STATION, DOMAIN + +PLATFORMS = ["camera", "sensor", "weather"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry): + """Set up EC as config entry.""" + lat = config_entry.data.get(CONF_LATITUDE) + lon = config_entry.data.get(CONF_LONGITUDE) + station = config_entry.data.get(CONF_STATION) + lang = config_entry.data.get(CONF_LANGUAGE, "English") + + weather_api = {} + + weather_init = partial( + ECData, station_id=station, coordinates=(lat, lon), language=lang.lower() + ) + weather_data = await hass.async_add_executor_job(weather_init) + weather_api["weather_data"] = weather_data + + radar_init = partial(ECRadar, coordinates=(lat, lon)) + radar_data = await hass.async_add_executor_job(radar_init) + weather_api["radar_data"] = radar_data + await hass.async_add_executor_job(radar_data.get_loop) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = weather_api + + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) + + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +def trigger_import(hass, config): + """Trigger a import of YAML config into a config_entry.""" + _LOGGER.warning( + "Environment Canada YAML configuration is deprecated; your YAML configuration " + "has been imported into the UI and can be safely removed" + ) + if not config.get(CONF_LANGUAGE): + config[CONF_LANGUAGE] = "English" + + data = {} + for key in ( + CONF_STATION, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_LANGUAGE, + ): # pylint: disable=consider-using-tuple + if config.get(key): + data[key] = config[key] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=data + ) + ) diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index ecd0c562d167f0166bafc0c0e221235ef1b9912a..0c8e1de6107187e16a3c0fcb51bb308f82a47a73 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -2,8 +2,10 @@ from __future__ import annotations import datetime +import logging -from env_canada import ECRadar +from env_canada import get_station_coords +from requests.exceptions import ConnectionError as RequestsConnectionError import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera @@ -16,15 +18,17 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -ATTR_UPDATED = "updated" +from . import trigger_import +from .const import CONF_ATTRIBUTION, CONF_STATION, DOMAIN -CONF_ATTRIBUTION = "Data provided by Environment Canada" -CONF_STATION = "station" CONF_LOOP = "loop" CONF_PRECIP_TYPE = "precip_type" +ATTR_UPDATED = "updated" MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10) +_LOGGER = logging.getLogger(__name__) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_LOOP, default=True): cv.boolean, @@ -37,35 +41,47 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Environment Canada camera.""" - if config.get(CONF_STATION): - radar_object = ECRadar( - station_id=config[CONF_STATION], precip_type=config.get(CONF_PRECIP_TYPE) + lat, lon = await hass.async_add_executor_job( + get_station_coords, config[CONF_STATION] ) else: lat = config.get(CONF_LATITUDE, hass.config.latitude) lon = config.get(CONF_LONGITUDE, hass.config.longitude) - radar_object = ECRadar( - coordinates=(lat, lon), precip_type=config.get(CONF_PRECIP_TYPE) - ) - add_devices( - [ECCamera(radar_object, config.get(CONF_NAME), config[CONF_LOOP])], True + config[CONF_LATITUDE] = lat + config[CONF_LONGITUDE] = lon + + trigger_import(hass, config) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + radar_data = hass.data[DOMAIN][config_entry.entry_id]["radar_data"] + + async_add_entities( + [ + ECCamera( + radar_data, + f"{config_entry.title} Radar", + f"{config_entry.unique_id}-radar", + ), + ] ) class ECCamera(Camera): """Implementation of an Environment Canada radar camera.""" - def __init__(self, radar_object, camera_name, is_loop): + def __init__(self, radar_object, camera_name, unique_id): """Initialize the camera.""" super().__init__() self.radar_object = radar_object - self.camera_name = camera_name - self.is_loop = is_loop + self._attr_name = camera_name + self._attr_unique_id = unique_id self.content_type = "image/gif" self.image = None self.timestamp = None @@ -77,13 +93,6 @@ class ECCamera(Camera): 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 "Environment Canada Radar" - @property def extra_state_attributes(self): """Return the state attributes of the device.""" @@ -92,8 +101,10 @@ class ECCamera(Camera): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update radar image.""" - if self.is_loop: + try: self.image = self.radar_object.get_loop() - else: - self.image = self.radar_object.get_latest_frame() + except RequestsConnectionError: + _LOGGER.warning("Radar data update failed due to rate limiting") + return + self.timestamp = self.radar_object.timestamp diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..c4c4835a44f07c4f4ff4a6ade25565b7963ae70d --- /dev/null +++ b/homeassistant/components/environment_canada/config_flow.py @@ -0,0 +1,108 @@ +"""Config flow for Environment Canada integration.""" +from functools import partial +import logging +import xml.etree.ElementTree as et + +import aiohttp +from env_canada import ECData +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import config_validation as cv + +from .const import CONF_LANGUAGE, CONF_STATION, CONF_TITLE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass, data): + """Validate the user input allows us to connect.""" + lat = data.get(CONF_LATITUDE) + lon = data.get(CONF_LONGITUDE) + station = data.get(CONF_STATION) + lang = data.get(CONF_LANGUAGE) + + weather_init = partial( + ECData, station_id=station, coordinates=(lat, lon), language=lang.lower() + ) + weather_data = await hass.async_add_executor_job(weather_init) + if weather_data.metadata.get("location") is None: + raise TooManyAttempts + + if lat is None or lon is None: + lat = weather_data.lat + lon = weather_data.lon + + return { + CONF_TITLE: weather_data.metadata.get("location"), + CONF_STATION: weather_data.station_id, + CONF_LATITUDE: lat, + CONF_LONGITUDE: lon, + } + + +class EnvironmentCanadaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Environment Canada weather.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except TooManyAttempts: + errors["base"] = "too_many_attempts" + except et.ParseError: + errors["base"] = "bad_station_id" + except aiohttp.ClientConnectionError: + errors["base"] = "cannot_connect" + except aiohttp.ClientResponseError as err: + if err.status == 404: + errors["base"] = "bad_station_id" + else: + errors["base"] = "error_response" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if not errors: + user_input[CONF_STATION] = info[CONF_STATION] + user_input[CONF_LATITUDE] = info[CONF_LATITUDE] + user_input[CONF_LONGITUDE] = info[CONF_LONGITUDE] + + # The combination of station and language are unique for all EC weather reporting + await self.async_set_unique_id( + f"{user_input[CONF_STATION]}-{user_input[CONF_LANGUAGE].lower()}" + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info[CONF_TITLE], data=user_input) + + data_schema = vol.Schema( + { + vol.Optional(CONF_STATION): str, + vol.Optional( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Optional( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + vol.Required(CONF_LANGUAGE, default="English"): vol.In( + ["English", "French"] + ), + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + async def async_step_import(self, import_data): + """Import entry from configuration.yaml.""" + return await self.async_step_user(import_data) + + +class TooManyAttempts(exceptions.HomeAssistantError): + """Error to indicate station ID is missing, invalid, or not in EC database.""" diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py new file mode 100644 index 0000000000000000000000000000000000000000..ff32f02b21e6f50bcc8139b5d654e1672950d08b --- /dev/null +++ b/homeassistant/components/environment_canada/const.py @@ -0,0 +1,9 @@ +"""Constants for EC component.""" + +ATTR_OBSERVATION_TIME = "observation_time" +ATTR_STATION = "station" +CONF_ATTRIBUTION = "Data provided by Environment Canada" +CONF_LANGUAGE = "language" +CONF_STATION = "station" +CONF_TITLE = "title" +DOMAIN = "environment_canada" diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 62c3e935d69a9a608ecc7e7da3592a9d3d7af508..e41c0969a87530d736efa1097cf6aadd2051b3d1 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,8 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.2.5"], - "codeowners": ["@michaeldavie"], + "requirements": ["env_canada==0.2.7"], + "codeowners": ["@gwww", "@michaeldavie"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 3690703d8d25d7e2fedad9a4720be3f8111e0e9b..8e4c14832618b085d193d8eb6267a262731c2844 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -3,7 +3,6 @@ from datetime import datetime, timedelta import logging import re -from env_canada import ECData import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -17,23 +16,20 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -_LOGGER = logging.getLogger(__name__) +from . import trigger_import +from .const import ATTR_STATION, CONF_ATTRIBUTION, CONF_LANGUAGE, CONF_STATION, DOMAIN SCAN_INTERVAL = timedelta(minutes=10) - ATTR_UPDATED = "updated" -ATTR_STATION = "station" ATTR_TIME = "alert time" -CONF_ATTRIBUTION = "Data provided by Environment Canada" -CONF_STATION = "station" -CONF_LANGUAGE = "language" +_LOGGER = logging.getLogger(__name__) def validate_station(station): """Check that the station ID is well-formed.""" if station is None: - return + return None 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 @@ -49,47 +45,45 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Environment Canada sensor.""" + trigger_import(hass, config) - if config.get(CONF_STATION): - ec_data = ECData( - station_id=config[CONF_STATION], language=config.get(CONF_LANGUAGE) - ) - else: - lat = config.get(CONF_LATITUDE, hass.config.latitude) - lon = config.get(CONF_LONGITUDE, hass.config.longitude) - ec_data = ECData(coordinates=(lat, lon), language=config.get(CONF_LANGUAGE)) - sensor_list = list(ec_data.conditions) + list(ec_data.alerts) - add_entities([ECSensor(sensor_type, ec_data) for sensor_type in sensor_list], True) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + weather_data = hass.data[DOMAIN][config_entry.entry_id]["weather_data"] + sensor_list = list(weather_data.conditions) + list(weather_data.alerts) + + async_add_entities( + [ + ECSensor( + sensor_type, + f"{config_entry.title} {sensor_type}", + weather_data, + f"{weather_data.metadata['location']}-{sensor_type}", + ) + for sensor_type in sensor_list + ], + True, + ) class ECSensor(SensorEntity): """Implementation of an Environment Canada sensor.""" - def __init__(self, sensor_type, ec_data): + def __init__(self, sensor_type, name, ec_data, unique_id): """Initialize the sensor.""" self.sensor_type = sensor_type self.ec_data = ec_data - self._unique_id = None - self._name = None + self._attr_unique_id = unique_id + self._attr_name = name self._state = None self._attr = None self._unit = None self._device_class = None - @property - def unique_id(self) -> str: - """Return the unique ID of the sensor.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def native_value(self): """Return the state of the sensor.""" @@ -119,9 +113,7 @@ class ECSensor(SensorEntity): metadata = self.ec_data.metadata sensor_data = conditions.get(self.sensor_type) - self._unique_id = f"{metadata['location']}-{self.sensor_type}" self._attr = {} - self._name = sensor_data.get("label") value = sensor_data.get("value") if isinstance(value, list): @@ -133,7 +125,9 @@ class ECSensor(SensorEntity): self._state = str(value).capitalize() elif value is not None and len(value) > 255: self._state = value[:255] - _LOGGER.info("Value for %s truncated to 255 characters", self._unique_id) + _LOGGER.info( + "Value for %s truncated to 255 characters", self._attr_unique_id + ) else: self._state = value diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..49686cba12325af0cdf95f0344cea541c3df135b --- /dev/null +++ b/homeassistant/components/environment_canada/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "step": { + "user": { + "title": "Environment Canada: weather location and language", + "description": "Either a station ID or latitude/longitude must be specified. The default latitude/longitude used are the values configured in your Home Assistant installation. The closest weather station to the coordinates will be used if specifying coordinates. If a station code is used it must follow the format: PP/code, where PP is the two-letter province and code is the station ID. The list of station IDs can be found here: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weather information can be retrieved in either English or French.", + "data": { + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "station": "Weather station ID", + "language": "Weather information language" + } + } + }, + "error": { + "bad_station_id": "Station ID is invalid, missing, or not found in the station ID database", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "error_response": "Response from Environment Canada in error", + "too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/components/environment_canada/translations/en.json b/homeassistant/components/environment_canada/translations/en.json new file mode 100644 index 0000000000000000000000000000000000000000..94c0b947fa43f99836b98f20c6318debfa2a60a6 --- /dev/null +++ b/homeassistant/components/environment_canada/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "error": { + "bad_station_id": "Station ID is invalid, missing, or not found in the station ID database", + "cannot_connect": "Failed to connect", + "error_response": "Response from Environment Canada in error", + "too_many_attempts": "Connections to Environment Canada are rate limited; Try again in 60 seconds", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "language": "Weather information language", + "latitude": "Latitude", + "longitude": "Longitude", + "station": "Weather station ID" + }, + "description": "Either a station ID or latitude/longitude must be specified. The default latitude/longitude used are the values configured in your Home Assistant installation. The closest weather station to the coordinates will be used if specifying coordinates. If a station code is used it must follow the format: PP/code, where PP is the two-letter province and code is the station ID. The list of station IDs can be found here: https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv. Weather information can be retrieved in either English or French.", + "title": "Environment Canada: weather location and language" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index cf24146da14ec9623912043c8aa8f101fe8218e7..281cf1174261fcce7d7fd11d6002baeaf17f0dd7 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -1,8 +1,8 @@ """Platform for retrieving meteorological data from Environment Canada.""" import datetime +import logging import re -from env_canada import ECData import voluptuous as vol from homeassistant.components.weather import ( @@ -30,17 +30,20 @@ from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_C import homeassistant.helpers.config_validation as cv from homeassistant.util import dt +from . import trigger_import +from .const import CONF_ATTRIBUTION, CONF_STATION, DOMAIN + CONF_FORECAST = "forecast" -CONF_ATTRIBUTION = "Data provided by Environment Canada" -CONF_STATION = "station" + +_LOGGER = logging.getLogger(__name__) def validate_station(station): """Check that the station ID is well-formed.""" if station is None: - return + return None 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###"') + raise vol.Invalid('Station ID must be of the form "XX/s0000###"') return station @@ -72,45 +75,59 @@ ICON_CONDITION_MAP = { } -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entries, discovery_info=None): """Set up the Environment Canada weather.""" - if config.get(CONF_STATION): - ec_data = ECData(station_id=config[CONF_STATION]) - else: - lat = config.get(CONF_LATITUDE, hass.config.latitude) - lon = config.get(CONF_LONGITUDE, hass.config.longitude) - ec_data = ECData(coordinates=(lat, lon)) + trigger_import(hass, config) - add_devices([ECWeather(ec_data, config)]) + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Add a weather entity from a config_entry.""" + weather_data = hass.data[DOMAIN][config_entry.entry_id]["weather_data"] + + async_add_entities( + [ + ECWeather( + weather_data, + f"{config_entry.title}", + config_entry.data, + "daily", + f"{config_entry.unique_id}-daily", + ), + ECWeather( + weather_data, + f"{config_entry.title} Hourly", + config_entry.data, + "hourly", + f"{config_entry.unique_id}-hourly", + ), + ] + ) class ECWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, ec_data, config): + def __init__(self, ec_data, name, config, forecast_type, unique_id): """Initialize Environment Canada weather.""" self.ec_data = ec_data - self.platform_name = config.get(CONF_NAME) - self.forecast_type = config[CONF_FORECAST] + self.config = config + self._attr_name = name + self._attr_unique_id = unique_id + self.forecast_type = forecast_type @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.metadata.get("location") - @property def temperature(self): """Return the temperature.""" if self.ec_data.conditions.get("temperature", {}).get("value"): return float(self.ec_data.conditions["temperature"]["value"]) - if self.ec_data.hourly_forecasts[0].get("temperature"): + if self.ec_data.hourly_forecasts and self.ec_data.hourly_forecasts[0].get( + "temperature" + ): return float(self.ec_data.hourly_forecasts[0]["temperature"]) return None @@ -161,7 +178,9 @@ class ECWeather(WeatherEntity): if self.ec_data.conditions.get("icon_code", {}).get("value"): icon_code = self.ec_data.conditions["icon_code"]["value"] - elif self.ec_data.hourly_forecasts[0].get("icon_code"): + elif self.ec_data.hourly_forecasts and self.ec_data.hourly_forecasts[0].get( + "icon_code" + ): icon_code = self.ec_data.hourly_forecasts[0]["icon_code"] if icon_code: @@ -184,6 +203,8 @@ def get_forecast(ec_data, forecast_type): if forecast_type == "daily": half_days = ec_data.daily_forecasts + if not half_days: + return None today = { ATTR_FORECAST_TIME: dt.now().isoformat(), diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ed698fde8dc220632349021b0c4b3a74b13aaebf..cf42c2bd24fecfc8e257fa5c8c2814e262741597 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -77,6 +77,7 @@ FLOWS = [ "emulated_roku", "enocean", "enphase_envoy", + "environment_canada", "epson", "esphome", "ezviz", diff --git a/requirements_all.txt b/requirements_all.txt index e91134d5d2f5af7459a80e1c67bbe2dc94324b9d..12b83a61bc0b9c04c78540b7783d423198f91ff4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -591,7 +591,7 @@ enocean==0.50 enturclient==0.2.2 # homeassistant.components.environment_canada -env_canada==0.2.5 +env_canada==0.2.7 # homeassistant.components.envirophat # envirophat==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e6e464acb7583e50bea5d23d1e387927670fd982..7341dcd518fd16cc3c4dd4c5f70e8d3e5dc94d8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -356,6 +356,9 @@ emulated_roku==0.2.1 # homeassistant.components.enocean enocean==0.50 +# homeassistant.components.environment_canada +env_canada==0.2.7 + # homeassistant.components.enphase_envoy envoy_reader==0.20.0 diff --git a/tests/components/environment_canada/__init__.py b/tests/components/environment_canada/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..65b0ed16207b27b92304d883114dd847a44b6684 --- /dev/null +++ b/tests/components/environment_canada/__init__.py @@ -0,0 +1 @@ +"""Tests for the Environment Canada integration.""" diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..192efb05f40742e87ce4522602327184dcf47b35 --- /dev/null +++ b/tests/components/environment_canada/test_config_flow.py @@ -0,0 +1,178 @@ +"""Test the Environment Canada (EC) config flow.""" +from unittest.mock import MagicMock, Mock, patch +import xml.etree.ElementTree as et + +import aiohttp +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.environment_canada.const import ( + CONF_LANGUAGE, + CONF_STATION, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE + +from tests.common import MockConfigEntry + +FAKE_CONFIG = { + CONF_STATION: "ON/s1234567", + CONF_LANGUAGE: "English", + CONF_LATITUDE: 42.42, + CONF_LONGITUDE: -42.42, +} +FAKE_TITLE = "Universal title!" + + +def mocked_ec( + station_id=FAKE_CONFIG[CONF_STATION], + lat=FAKE_CONFIG[CONF_LATITUDE], + lon=FAKE_CONFIG[CONF_LONGITUDE], + lang=FAKE_CONFIG[CONF_LANGUAGE], + update=None, + metadata={"location": FAKE_TITLE}, +): + """Mock the env_canada library.""" + ec_mock = MagicMock() + ec_mock.station_id = station_id + ec_mock.lat = lat + ec_mock.lon = lon + ec_mock.language = lang + ec_mock.metadata = metadata + + if update: + ec_mock.update = update + else: + ec_mock.update = Mock() + + return patch( + "homeassistant.components.environment_canada.config_flow.ECData", + return_value=ec_mock, + ) + + +async def test_create_entry(hass): + """Test creating an entry.""" + with mocked_ec(), patch( + "homeassistant.components.environment_canada.async_setup_entry", + return_value=True, + ): + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], FAKE_CONFIG + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == FAKE_CONFIG + assert result["title"] == FAKE_TITLE + + +async def test_create_same_entry_twice(hass): + """Test duplicate entries.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=FAKE_CONFIG, + unique_id="ON/s1234567-english", + ) + entry.add_to_hass(hass) + + with mocked_ec(), patch( + "homeassistant.components.environment_canada.async_setup_entry", + return_value=True, + ): + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], FAKE_CONFIG + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_too_many_attempts(hass): + """Test hitting rate limit.""" + with mocked_ec(metadata={}), patch( + "homeassistant.components.environment_canada.async_setup_entry", + return_value=True, + ): + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "too_many_attempts"} + + +@pytest.mark.parametrize( + "error", + [ + (aiohttp.ClientResponseError(Mock(), (), status=404), "bad_station_id"), + (aiohttp.ClientResponseError(Mock(), (), status=400), "error_response"), + (aiohttp.ClientConnectionError, "cannot_connect"), + (et.ParseError, "bad_station_id"), + (ValueError, "unknown"), + ], +) +async def test_exception_handling(hass, error): + """Test exception handling.""" + exc, base_error = error + with patch( + "homeassistant.components.environment_canada.config_flow.ECData", + side_effect=exc, + ): + flow = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + flow["flow_id"], + {}, + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["errors"] == {"base": base_error} + + +async def test_lat_or_lon_not_specified(hass): + """Test that the import step works.""" + with mocked_ec(), patch( + "homeassistant.components.environment_canada.async_setup_entry", + return_value=True, + ): + fake_config = dict(FAKE_CONFIG) + del fake_config[CONF_LATITUDE] + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=fake_config + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == FAKE_CONFIG + assert result["title"] == FAKE_TITLE + + +async def test_async_step_import(hass): + """Test that the import step works.""" + with mocked_ec(), patch( + "homeassistant.components.environment_canada.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=FAKE_CONFIG + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == FAKE_CONFIG + assert result["title"] == FAKE_TITLE + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=FAKE_CONFIG + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT