diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py index 81ab8f98014..17fde104125 100644 --- a/homeassistant/components/ipma/config_flow.py +++ b/homeassistant/components/ipma/config_flow.py @@ -18,6 +18,13 @@ class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Init IpmaFlowHandler.""" self._errors = {} + async def async_step_import(self, config): + """Import a configuration from config.yaml.""" + + self._async_abort_entries_match(config) + config[CONF_MODE] = "daily" + return await self.async_step_user(user_input=config) + async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" self._errors = {} diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 902a03b6c83..a391b24e3b4 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -3,7 +3,7 @@ "name": "Instituto Portugu\u00eas do Mar e Atmosfera (IPMA)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipma", - "requirements": ["pyipma==2.0.5"], + "requirements": ["pyipma==3.0.2"], "codeowners": ["@dgomes", "@abmantis"], "iot_class": "cloud_polling", "loggers": ["geopy", "pyipma"] diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 7a3a28b8bd0..d20e5cb2f21 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -6,10 +6,12 @@ import logging import async_timeout from pyipma.api import IPMA_API +from pyipma.forecast import Forecast from pyipma.location import Location import voluptuous as vol from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, ATTR_CONDITION_EXCEPTIONAL, ATTR_CONDITION_FOG, @@ -48,9 +50,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.sun import is_up from homeassistant.util import Throttle -from homeassistant.util.dt import now, parse_datetime _LOGGER = logging.getLogger(__name__) @@ -73,6 +74,7 @@ CONDITION_CLASSES = { ATTR_CONDITION_WINDY: [], ATTR_CONDITION_WINDY_VARIANT: [], ATTR_CONDITION_EXCEPTIONAL: [], + ATTR_CONDITION_CLEAR_NIGHT: [-1], } FORECAST_MODE = ["hourly", "daily"] @@ -87,31 +89,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the ipma platform. - - Deprecated. - """ - _LOGGER.warning("Loading IPMA via platform config is deprecated") - - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - if None in (latitude, longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return - - api = await async_get_api(hass) - location = await async_get_location(hass, api, latitude, longitude) - - async_add_entities([IPMAWeather(location, api, config)], True) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -180,21 +157,24 @@ class IPMAWeather(WeatherEntity): _attr_native_temperature_unit = TEMP_CELSIUS _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + _attr_attribution = ATTRIBUTION + def __init__(self, location: Location, api: IPMA_API, config): """Initialise the platform with a data instance and station name.""" self._api = api self._location_name = config.get(CONF_NAME, location.name) self._mode = config.get(CONF_MODE) + self._period = 1 if config.get(CONF_MODE) == "hourly" else 24 self._location = location self._observation = None - self._forecast = None + self._forecast: list[Forecast] = [] @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Update Condition and Forecast.""" async with async_timeout.timeout(10): new_observation = await self._location.observation(self._api) - new_forecast = await self._location.forecast(self._api) + new_forecast = await self._location.forecast(self._api, self._period) if new_observation: self._observation = new_observation @@ -207,8 +187,9 @@ class IPMAWeather(WeatherEntity): _LOGGER.warning("Could not update weather forecast") _LOGGER.debug( - "Updated location %s, observation %s", + "Updated location %s based on %s, current observation %s", self._location.name, + self._location.station, self._observation, ) @@ -217,30 +198,28 @@ class IPMAWeather(WeatherEntity): """Return a unique id.""" return f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}" - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - @property def name(self): """Return the name of the station.""" return self._location_name + def _condition_conversion(self, identifier, forecast_dt): + """Convert from IPMA weather_type id to HA.""" + if identifier == 1 and not is_up(self.hass, forecast_dt): + identifier = -identifier + + return next( + (k for k, v in CONDITION_CLASSES.items() if identifier in v), + None, + ) + @property def condition(self): """Return the current condition.""" if not self._forecast: return - return next( - ( - k - for k, v in CONDITION_CLASSES.items() - if self._forecast[0].weather_type in v - ), - None, - ) + return self._condition_conversion(self._forecast[0].weather_type.id, None) @property def native_temperature(self): @@ -288,57 +267,17 @@ class IPMAWeather(WeatherEntity): if not self._forecast: return [] - if self._mode == "hourly": - forecast_filtered = [ - x - for x in self._forecast - if x.forecasted_hours == 1 - and parse_datetime(x.forecast_date) - > (now().utcnow() - timedelta(hours=1)) - ] - - fcdata_out = [ - { - ATTR_FORECAST_TIME: data_in.forecast_date, - ATTR_FORECAST_CONDITION: next( - ( - k - for k, v in CONDITION_CLASSES.items() - if int(data_in.weather_type) in v - ), - None, - ), - ATTR_FORECAST_NATIVE_TEMP: float(data_in.feels_like_temperature), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( - int(float(data_in.precipitation_probability)) - if int(float(data_in.precipitation_probability)) >= 0 - else None - ), - ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, - ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, - } - for data_in in forecast_filtered - ] - else: - forecast_filtered = [f for f in self._forecast if f.forecasted_hours == 24] - fcdata_out = [ - { - ATTR_FORECAST_TIME: data_in.forecast_date, - ATTR_FORECAST_CONDITION: next( - ( - k - for k, v in CONDITION_CLASSES.items() - if int(data_in.weather_type) in v - ), - None, - ), - ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.min_temperature, - ATTR_FORECAST_NATIVE_TEMP: data_in.max_temperature, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.precipitation_probability, - ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, - ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, - } - for data_in in forecast_filtered - ] - - return fcdata_out + return [ + { + ATTR_FORECAST_TIME: data_in.forecast_date, + ATTR_FORECAST_CONDITION: self._condition_conversion( + data_in.weather_type.id, data_in.forecast_date + ), + ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.min_temperature, + ATTR_FORECAST_NATIVE_TEMP: data_in.max_temperature, + ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.precipitation_probability, + ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, + ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, + } + for data_in in self._forecast + ] diff --git a/requirements_all.txt b/requirements_all.txt index 7c299ce0893..f203f1bcca5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1602,7 +1602,7 @@ pyinsteon==1.2.0 pyintesishome==1.8.0 # homeassistant.components.ipma -pyipma==2.0.5 +pyipma==3.0.2 # homeassistant.components.ipp pyipp==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a74fba107c..f9efb70b5b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1118,7 +1118,7 @@ pyicloud==1.0.0 pyinsteon==1.2.0 # homeassistant.components.ipma -pyipma==2.0.5 +pyipma==3.0.2 # homeassistant.components.ipp pyipp==0.11.0 diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py index 8c96b9a01d8..c8d53f95a4a 100644 --- a/tests/components/ipma/test_config_flow.py +++ b/tests/components/ipma/test_config_flow.py @@ -2,8 +2,10 @@ from unittest.mock import Mock, patch +from homeassistant import config_entries from homeassistant.components.ipma import DOMAIN, config_flow -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -11,6 +13,13 @@ from .test_weather import MockLocation from tests.common import MockConfigEntry, mock_registry +ENTRY_CONFIG = { + CONF_NAME: "Home Town", + CONF_LATITUDE: "1", + CONF_LONGITUDE: "2", + CONF_MODE: "hourly", +} + async def test_show_config_form(): """Test show configuration form.""" @@ -172,3 +181,45 @@ async def test_config_entry_migration(hass): weather_home2 = ent_reg.async_get("weather.hometown_2") assert weather_home2.unique_id == "0, 0, hourly" + + +async def test_import_flow_success(hass): + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.ipma.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=ENTRY_CONFIG, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Home Town" + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_already_exist(hass): + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data=ENTRY_CONFIG, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.ipma.async_setup_entry", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=ENTRY_CONFIG, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.ABORT + assert result3["reason"] == "already_configured" diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index e6469043474..942b9654895 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -1,8 +1,10 @@ """The tests for the IPMA weather component.""" from collections import namedtuple +from datetime import datetime, timezone from unittest.mock import patch -from homeassistant.components import weather +from freezegun import freeze_time + from homeassistant.components.weather import ( ATTR_FORECAST, ATTR_FORECAST_CONDITION, @@ -19,8 +21,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, ) -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import now +from homeassistant.const import STATE_UNKNOWN from tests.common import MockConfigEntry @@ -31,6 +32,13 @@ TEST_CONFIG = { "mode": "daily", } +TEST_CONFIG_HOURLY = { + "name": "HomeTown", + "latitude": "40.00", + "longitude": "-8.00", + "mode": "hourly", +} + class MockLocation: """Mock Location from pyipma.""" @@ -52,7 +60,7 @@ class MockLocation: return Observation(0.0, 71.0, 1000.0, 0.0, 18.0, "NW", 3.94) - async def forecast(self, api): + async def forecast(self, api, period): """Mock Forecast.""" Forecast = namedtuple( "Forecast", @@ -72,42 +80,67 @@ class MockLocation: ], ) - return [ - Forecast( - None, - "2020-01-15T00:00:00", - 24, - None, - 16.2, - 10.6, - "100.0", - 13.4, - "2020-01-15T07:51:00", - 9, - "S", - "10", - ), - Forecast( - "7.7", - now().utcnow().strftime("%Y-%m-%dT%H:%M:%S"), - 1, - "86.9", - None, - None, - "80.0", - 10.6, - "2020-01-15T07:51:00", - 10, - "S", - "32.7", - ), - ] + WeatherType = namedtuple("WeatherType", ["id", "en", "pt"]) + + if period == 24: + return [ + Forecast( + None, + datetime(2020, 1, 16, 0, 0, 0), + 24, + None, + 16.2, + 10.6, + "100.0", + 13.4, + "2020-01-15T07:51:00", + WeatherType(9, "Rain/showers", "Chuva/aguaceiros"), + "S", + "10", + ), + ] + if period == 1: + return [ + Forecast( + "7.7", + datetime(2020, 1, 15, 1, 0, 0, tzinfo=timezone.utc), + 1, + "86.9", + 12.0, + None, + 80.0, + 10.6, + "2020-01-15T02:51:00", + WeatherType(10, "Light rain", "Chuva fraca ou chuvisco"), + "S", + "32.7", + ), + Forecast( + "5.7", + datetime(2020, 1, 15, 2, 0, 0, tzinfo=timezone.utc), + 1, + "86.9", + 12.0, + None, + 80.0, + 10.6, + "2020-01-15T02:51:00", + WeatherType(1, "Clear sky", "C\u00e9u limpo"), + "S", + "32.7", + ), + ] @property def name(self): """Mock location.""" return "HomeTown" + @property + def station(self): + """Mock station.""" + return "HomeTown Station" + @property def station_latitude(self): """Mock latitude.""" @@ -129,35 +162,22 @@ class MockLocation: return 0 -async def test_setup_configuration(hass): - """Test for successfully setting up the IPMA platform.""" - with patch( - "homeassistant.components.ipma.weather.async_get_location", - return_value=MockLocation(), - ): - assert await async_setup_component( - hass, - weather.DOMAIN, - {"weather": {"name": "HomeTown", "platform": "ipma", "mode": "hourly"}}, - ) - await hass.async_block_till_done() +class MockBadLocation(MockLocation): + """Mock Location with unresponsive api.""" - state = hass.states.get("weather.hometown") - assert state.state == "rainy" + async def observation(self, api): + """Mock Observation.""" + return None - data = state.attributes - assert data.get(ATTR_WEATHER_TEMPERATURE) == 18.0 - assert data.get(ATTR_WEATHER_HUMIDITY) == 71 - assert data.get(ATTR_WEATHER_PRESSURE) == 1000.0 - assert data.get(ATTR_WEATHER_WIND_SPEED) == 3.94 - assert data.get(ATTR_WEATHER_WIND_BEARING) == "NW" - assert state.attributes.get("friendly_name") == "HomeTown" + async def forecast(self, api, period): + """Mock Forecast.""" + return [] async def test_setup_config_flow(hass): """Test for successfully setting up the IPMA platform.""" with patch( - "homeassistant.components.ipma.weather.async_get_location", + "pyipma.location.Location.get", return_value=MockLocation(), ): entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG) @@ -179,21 +199,18 @@ async def test_setup_config_flow(hass): async def test_daily_forecast(hass): """Test for successfully getting daily forecast.""" with patch( - "homeassistant.components.ipma.weather.async_get_location", + "pyipma.location.Location.get", return_value=MockLocation(), ): - assert await async_setup_component( - hass, - weather.DOMAIN, - {"weather": {"name": "HomeTown", "platform": "ipma", "mode": "daily"}}, - ) + entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG) + await hass.config_entries.async_forward_entry_setup(entry, WEATHER_DOMAIN) await hass.async_block_till_done() state = hass.states.get("weather.hometown") assert state.state == "rainy" forecast = state.attributes.get(ATTR_FORECAST)[0] - assert forecast.get(ATTR_FORECAST_TIME) == "2020-01-15T00:00:00" + assert forecast.get(ATTR_FORECAST_TIME) == datetime(2020, 1, 16, 0, 0, 0) assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy" assert forecast.get(ATTR_FORECAST_TEMP) == 16.2 assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6 @@ -202,17 +219,15 @@ async def test_daily_forecast(hass): assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" +@freeze_time("2020-01-14 23:00:00") async def test_hourly_forecast(hass): """Test for successfully getting daily forecast.""" with patch( - "homeassistant.components.ipma.weather.async_get_location", + "pyipma.location.Location.get", return_value=MockLocation(), ): - assert await async_setup_component( - hass, - weather.DOMAIN, - {"weather": {"name": "HomeTown", "platform": "ipma", "mode": "hourly"}}, - ) + entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG_HOURLY) + await hass.config_entries.async_forward_entry_setup(entry, WEATHER_DOMAIN) await hass.async_block_till_done() state = hass.states.get("weather.hometown") @@ -220,7 +235,29 @@ async def test_hourly_forecast(hass): forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy" - assert forecast.get(ATTR_FORECAST_TEMP) == 7.7 + assert forecast.get(ATTR_FORECAST_TEMP) == 12.0 assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 80.0 assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 32.7 assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" + + +async def test_failed_get_observation_forecast(hass): + """Test for successfully setting up the IPMA platform.""" + with patch( + "pyipma.location.Location.get", + return_value=MockBadLocation(), + ): + entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG) + await hass.config_entries.async_forward_entry_setup(entry, WEATHER_DOMAIN) + await hass.async_block_till_done() + + state = hass.states.get("weather.hometown") + assert state.state == STATE_UNKNOWN + + data = state.attributes + assert data.get(ATTR_WEATHER_TEMPERATURE) is None + assert data.get(ATTR_WEATHER_HUMIDITY) is None + assert data.get(ATTR_WEATHER_PRESSURE) is None + assert data.get(ATTR_WEATHER_WIND_SPEED) is None + assert data.get(ATTR_WEATHER_WIND_BEARING) is None + assert state.attributes.get("friendly_name") == "HomeTown"