diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py index 0821059fcdf..01d74179f41 100644 --- a/homeassistant/components/environment_canada/__init__.py +++ b/homeassistant/components/environment_canada/__init__.py @@ -1,14 +1,19 @@ """The Environment Canada (EC) component.""" -from functools import partial +from datetime import timedelta import logging +import xml.etree.ElementTree as et -from env_canada import ECData, ECRadar +from env_canada import ECRadar, ECWeather, ec_exc from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_LANGUAGE, CONF_STATION, DOMAIN +DEFAULT_RADAR_UPDATE_INTERVAL = timedelta(minutes=5) +DEFAULT_WEATHER_UPDATE_INTERVAL = timedelta(minutes=5) + PLATFORMS = ["camera", "sensor", "weather"] _LOGGER = logging.getLogger(__name__) @@ -21,21 +26,26 @@ async def async_setup_entry(hass, config_entry): station = config_entry.data.get(CONF_STATION) lang = config_entry.data.get(CONF_LANGUAGE, "English") - weather_api = {} + coordinators = {} - weather_init = partial( - ECData, station_id=station, coordinates=(lat, lon), language=lang.lower() + weather_data = ECWeather( + 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 + coordinators["weather_coordinator"] = ECDataUpdateCoordinator( + hass, weather_data, "weather", DEFAULT_WEATHER_UPDATE_INTERVAL + ) + await coordinators["weather_coordinator"].async_config_entry_first_refresh() - 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) + radar_data = ECRadar(coordinates=(lat, lon)) + coordinators["radar_coordinator"] = ECDataUpdateCoordinator( + hass, radar_data, "radar", DEFAULT_RADAR_UPDATE_INTERVAL + ) + await coordinators["radar_coordinator"].async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = weather_api + hass.data[DOMAIN][config_entry.entry_id] = coordinators hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @@ -77,3 +87,22 @@ def trigger_import(hass, config): DOMAIN, context={"source": SOURCE_IMPORT}, data=data ) ) + + +class ECDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching EC data.""" + + def __init__(self, hass, ec_data, name, update_interval): + """Initialize global EC data updater.""" + super().__init__( + hass, _LOGGER, name=f"{DOMAIN} {name}", update_interval=update_interval + ) + self.ec_data = ec_data + + async def _async_update_data(self): + """Fetch data from EC.""" + try: + await self.ec_data.update() + except (et.ParseError, ec_exc.UnknownStationId) as ex: + raise UpdateFailed(f"Error fetching {self.name} data: {ex}") from ex + return self.ec_data diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 0c8e1de6107..a4707bb7576 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,33 +1,18 @@ """Support for the Environment Canada radar imagery.""" from __future__ import annotations -import datetime -import logging - -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 -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, -) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import trigger_import -from .const import CONF_ATTRIBUTION, CONF_STATION, DOMAIN +from .const import ATTR_OBSERVATION_TIME, CONF_STATION, DOMAIN 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( { @@ -43,13 +28,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Environment Canada camera.""" - if config.get(CONF_STATION): - 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) + lat = config.get(CONF_LATITUDE, hass.config.latitude) + lon = config.get(CONF_LONGITUDE, hass.config.longitude) config[CONF_LATITUDE] = lat config[CONF_LONGITUDE] = lon @@ -59,52 +39,35 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 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", - ), - ] - ) + coordinator = hass.data[DOMAIN][config_entry.entry_id]["radar_coordinator"] + async_add_entities([ECCamera(coordinator)]) -class ECCamera(Camera): +class ECCamera(CoordinatorEntity, Camera): """Implementation of an Environment Canada radar camera.""" - def __init__(self, radar_object, camera_name, unique_id): + def __init__(self, coordinator): """Initialize the camera.""" - super().__init__() + super().__init__(coordinator) + Camera.__init__(self) + + self.radar_object = coordinator.ec_data + self._attr_name = f"{coordinator.config_entry.title} Radar" + self._attr_unique_id = f"{coordinator.config_entry.unique_id}-radar" + self._attr_attribution = self.radar_object.metadata["attribution"] - self.radar_object = radar_object - self._attr_name = camera_name - self._attr_unique_id = unique_id self.content_type = "image/gif" self.image = None - self.timestamp = None + self.observation_time = None def camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return bytes of camera image.""" - self.update() - return self.image + self.observation_time = self.radar_object.timestamp + return self.radar_object.image @property def extra_state_attributes(self): """Return the state attributes of the device.""" - return {ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_UPDATED: self.timestamp} - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update radar image.""" - try: - self.image = self.radar_object.get_loop() - except RequestsConnectionError: - _LOGGER.warning("Radar data update failed due to rate limiting") - return - - self.timestamp = self.radar_object.timestamp + return {ATTR_OBSERVATION_TIME: self.observation_time} diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py index c4c4835a44f..e1eda36c345 100644 --- a/homeassistant/components/environment_canada/config_flow.py +++ b/homeassistant/components/environment_canada/config_flow.py @@ -1,13 +1,12 @@ """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 +from env_canada import ECWeather, ec_exc import voluptuous as vol -from homeassistant import config_entries, exceptions +from homeassistant import config_entries from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.helpers import config_validation as cv @@ -16,19 +15,19 @@ from .const import CONF_LANGUAGE, CONF_STATION, CONF_TITLE, DOMAIN _LOGGER = logging.getLogger(__name__) -async def validate_input(hass, data): +async def validate_input(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 = ECWeather( + 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 + await weather_data.update() if lat is None or lon is None: lat = weather_data.lat @@ -52,10 +51,8 @@ class EnvironmentCanadaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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: + info = await validate_input(user_input) + except (et.ParseError, vol.MultipleInvalid, ec_exc.UnknownStationId): errors["base"] = "bad_station_id" except aiohttp.ClientConnectionError: errors["base"] = "cannot_connect" @@ -102,7 +99,3 @@ class EnvironmentCanadaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 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 index ff32f02b21e..16f7dc1cf99 100644 --- a/homeassistant/components/environment_canada/const.py +++ b/homeassistant/components/environment_canada/const.py @@ -2,7 +2,6 @@ ATTR_OBSERVATION_TIME = "observation_time" ATTR_STATION = "station" -CONF_ATTRIBUTION = "Data provided by Environment Canada" CONF_LANGUAGE = "language" CONF_STATION = "station" CONF_TITLE = "title" diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index e41c0969a87..3a2ee1d8b8f 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,7 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.2.7"], + "requirements": ["env_canada==0.5.14"], "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 48c9ed57dee..0d33a166254 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -1,5 +1,4 @@ """Support for the Environment Canada weather service.""" -from datetime import datetime, timedelta import logging import re @@ -7,7 +6,6 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_LATITUDE, CONF_LONGITUDE, @@ -15,12 +13,17 @@ from homeassistant.const import ( TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import trigger_import -from .const import ATTR_STATION, CONF_ATTRIBUTION, CONF_LANGUAGE, CONF_STATION, DOMAIN +from .const import ( + ATTR_OBSERVATION_TIME, + ATTR_STATION, + CONF_LANGUAGE, + CONF_STATION, + DOMAIN, +) -SCAN_INTERVAL = timedelta(minutes=10) -ATTR_UPDATED = "updated" ATTR_TIME = "alert time" _LOGGER = logging.getLogger(__name__) @@ -31,7 +34,7 @@ def validate_station(station): if station is None: 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 @@ -52,43 +55,40 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 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) + coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] + weather_data = coordinator.ec_data + + sensors = list(weather_data.conditions) + labels = [weather_data.conditions[sensor]["label"] for sensor in sensors] + alerts_list = list(weather_data.alerts) + labels = labels + [weather_data.alerts[sensor]["label"] for sensor in alerts_list] + sensors = sensors + alerts_list 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 + ECSensor(coordinator, sensor, label) + for sensor, label in zip(sensors, labels) ], True, ) -class ECSensor(SensorEntity): +class ECSensor(CoordinatorEntity, SensorEntity): """Implementation of an Environment Canada sensor.""" - def __init__(self, sensor_type, name, ec_data, unique_id): + def __init__(self, coordinator, sensor, label): """Initialize the sensor.""" - self.sensor_type = sensor_type - self.ec_data = ec_data + super().__init__(coordinator) + self.sensor_type = sensor + self.ec_data = coordinator.ec_data - self._attr_unique_id = unique_id - self._attr_name = name - self._state = None + self._attr_attribution = self.ec_data.metadata["attribution"] + self._attr_name = f"{coordinator.config_entry.title} {label}" + self._attr_unique_id = f"{self.ec_data.metadata['location']}-{sensor}" self._attr = None self._unit = None self._device_class = None - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - @property def extra_state_attributes(self): """Return the state attributes of the device.""" @@ -104,32 +104,31 @@ class ECSensor(SensorEntity): """Return the class of this device, from component DEVICE_CLASSES.""" return self._device_class - def update(self): + @property + def native_value(self): """Update current conditions.""" - self.ec_data.update() - self.ec_data.conditions.update(self.ec_data.alerts) - - conditions = self.ec_data.conditions metadata = self.ec_data.metadata - sensor_data = conditions.get(self.sensor_type) + sensor_data = self.ec_data.conditions.get(self.sensor_type) + if not sensor_data: + sensor_data = self.ec_data.alerts.get(self.sensor_type) self._attr = {} value = sensor_data.get("value") if isinstance(value, list): - self._state = " | ".join([str(s.get("title")) for s in value])[:255] + state = " | ".join([str(s.get("title")) for s in value])[:255] self._attr.update( {ATTR_TIME: " | ".join([str(s.get("date")) for s in value])} ) elif self.sensor_type == "tendency": - self._state = str(value).capitalize() - elif value is not None and len(value) > 255: - self._state = value[:255] + state = str(value).capitalize() + elif isinstance(value, str) and len(value) > 255: + state = value[:255] _LOGGER.info( "Value for %s truncated to 255 characters", self._attr_unique_id ) else: - self._state = value + state = value if sensor_data.get("unit") == "C" or self.sensor_type in ( "wind_chill", @@ -140,16 +139,11 @@ class ECSensor(SensorEntity): else: self._unit = sensor_data.get("unit") - if timestamp := metadata.get("timestamp"): - updated_utc = datetime.strptime(timestamp, "%Y%m%d%H%M%S").isoformat() - else: - updated_utc = None - self._attr.update( { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_UPDATED: updated_utc, + ATTR_OBSERVATION_TIME: metadata.get("timestamp"), ATTR_LOCATION: metadata.get("location"), ATTR_STATION: metadata.get("station"), } ) + return state diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index e54e9f8767d..5231e95e2bc 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -1,6 +1,7 @@ """Platform for retrieving meteorological data from Environment Canada.""" +from __future__ import annotations + import datetime -import logging import re import voluptuous as vol @@ -28,15 +29,14 @@ from homeassistant.components.weather import ( ) from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt from . import trigger_import -from .const import CONF_ATTRIBUTION, CONF_STATION, DOMAIN +from .const import CONF_STATION, DOMAIN CONF_FORECAST = "forecast" -_LOGGER = logging.getLogger(__name__) - def validate_station(station): """Check that the station ID is well-formed.""" @@ -82,43 +82,25 @@ async def async_setup_platform(hass, config, async_add_entries, discovery_info=N 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", - ), - ] - ) + coordinator = hass.data[DOMAIN][config_entry.entry_id]["weather_coordinator"] + async_add_entities([ECWeather(coordinator, False), ECWeather(coordinator, True)]) -class ECWeather(WeatherEntity): +class ECWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" - def __init__(self, ec_data, name, config, forecast_type, unique_id): + def __init__(self, coordinator, hourly): """Initialize Environment Canada weather.""" - self.ec_data = ec_data - 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 + super().__init__(coordinator) + self.ec_data = coordinator.ec_data + self._attr_attribution = self.ec_data.metadata["attribution"] + self._attr_name = ( + f"{coordinator.config_entry.title}{' Hourly' if hourly else ''}" + ) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}{'-hourly' if hourly else '-daily'}" + ) + self._hourly = hourly @property def temperature(self): @@ -190,18 +172,14 @@ class ECWeather(WeatherEntity): @property def forecast(self): """Return the forecast array.""" - return get_forecast(self.ec_data, self.forecast_type) - - def update(self): - """Get the latest data from Environment Canada.""" - self.ec_data.update() + return get_forecast(self.ec_data, self._hourly) -def get_forecast(ec_data, forecast_type): +def get_forecast(ec_data, hourly): """Build the forecast array.""" forecast_array = [] - if forecast_type == "daily": + if not hourly: if not (half_days := ec_data.daily_forecasts): return None @@ -251,15 +229,11 @@ def get_forecast(ec_data, forecast_type): } ) - elif forecast_type == "hourly": + else: for hour in ec_data.hourly_forecasts: forecast_array.append( { - ATTR_FORECAST_TIME: datetime.datetime.strptime( - hour["period"], "%Y%m%d%H%M%S" - ) - .replace(tzinfo=dt.UTC) - .isoformat(), + ATTR_FORECAST_TIME: hour["period"], ATTR_FORECAST_TEMP: int(hour["temperature"]), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(hour["icon_code"]) diff --git a/requirements_all.txt b/requirements_all.txt index 27883930013..71b3d73a761 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -597,7 +597,7 @@ enocean==0.50 enturclient==0.2.2 # homeassistant.components.environment_canada -env_canada==0.2.7 +env_canada==0.5.14 # homeassistant.components.envirophat # envirophat==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 046ef46af49..bf73128d453 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -366,7 +366,7 @@ emulated_roku==0.2.1 enocean==0.50 # homeassistant.components.environment_canada -env_canada==0.2.7 +env_canada==0.5.14 # homeassistant.components.enphase_envoy envoy_reader==0.20.0 diff --git a/tests/components/environment_canada/test_config_flow.py b/tests/components/environment_canada/test_config_flow.py index 192efb05f40..f2ebb48346c 100644 --- a/tests/components/environment_canada/test_config_flow.py +++ b/tests/components/environment_canada/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Environment Canada (EC) config flow.""" -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import xml.etree.ElementTree as et import aiohttp @@ -44,10 +44,10 @@ def mocked_ec( if update: ec_mock.update = update else: - ec_mock.update = Mock() + ec_mock.update = AsyncMock() return patch( - "homeassistant.components.environment_canada.config_flow.ECData", + "homeassistant.components.environment_canada.config_flow.ECWeather", return_value=ec_mock, ) @@ -94,24 +94,6 @@ async def test_create_same_entry_twice(hass): 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", [ @@ -126,7 +108,7 @@ async def test_exception_handling(hass, error): """Test exception handling.""" exc, base_error = error with patch( - "homeassistant.components.environment_canada.config_flow.ECData", + "homeassistant.components.environment_canada.config_flow.ECWeather", side_effect=exc, ): flow = await hass.config_entries.flow.async_init(