diff --git a/custom_components/irm_kmi/camera.py b/custom_components/irm_kmi/camera.py index 09e26c7..311454b 100644 --- a/custom_components/irm_kmi/camera.py +++ b/custom_components/irm_kmi/camera.py @@ -48,7 +48,7 @@ def __init__(self, @property def frame_interval(self) -> float: """Return the interval between frames of the mjpeg stream.""" - return 20 + return 1 def camera_image(self, width: int | None = None, diff --git a/custom_components/irm_kmi/config_flow.py b/custom_components/irm_kmi/config_flow.py index e907336..1a7dbe7 100644 --- a/custom_components/irm_kmi/config_flow.py +++ b/custom_components/irm_kmi/config_flow.py @@ -1,23 +1,26 @@ """Config flow to set up IRM KMI integration via the UI.""" import logging +import async_timeout import voluptuous as vol from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow -from homeassistant.const import CONF_ZONE +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import (EntitySelector, EntitySelectorConfig, SelectSelector, SelectSelectorConfig, SelectSelectorMode) +from .api import IrmKmiApiClient from .const import (CONF_DARK_MODE, CONF_STYLE, CONF_STYLE_OPTIONS, CONF_USE_DEPRECATED_FORECAST, CONF_USE_DEPRECATED_FORECAST_OPTIONS, CONFIG_FLOW_VERSION, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED, - OPTION_STYLE_STD) + OPTION_STYLE_STD, OUT_OF_BENELUX) from .utils import get_config_value _LOGGER = logging.getLogger(__name__) @@ -34,23 +37,46 @@ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: async def async_step_user(self, user_input: dict | None = None) -> FlowResult: """Define the user step of the configuration flow.""" + errors = {} + if user_input is not None: _LOGGER.debug(f"Provided config user is: {user_input}") - await self.async_set_unique_id(user_input[CONF_ZONE]) - self._abort_if_unique_id_configured() - - state = self.hass.states.get(user_input[CONF_ZONE]) - return self.async_create_entry( - title=state.name if state else "IRM KMI", - data={CONF_ZONE: user_input[CONF_ZONE], - CONF_STYLE: user_input[CONF_STYLE], - CONF_DARK_MODE: user_input[CONF_DARK_MODE], - CONF_USE_DEPRECATED_FORECAST: user_input[CONF_USE_DEPRECATED_FORECAST]}, - ) + if (zone := self.hass.states.get(user_input[CONF_ZONE])) is None: + errors[CONF_ZONE] = 'Zone does not exist' + + # Check if zone is in Benelux + if not errors: + api_data = {} + try: + async with async_timeout.timeout(10): + api_data = await IrmKmiApiClient( + session=async_get_clientsession(self.hass)).get_forecasts_coord( + {'lat': zone.attributes[ATTR_LATITUDE], + 'long': zone.attributes[ATTR_LONGITUDE]} + ) + except Exception: + errors['base'] = "Could not get data from the API" + + if api_data.get('cityName', None) in OUT_OF_BENELUX: + errors[CONF_ZONE] = 'Zone is outside of Benelux' + + if not errors: + await self.async_set_unique_id(user_input[CONF_ZONE]) + self._abort_if_unique_id_configured() + + state = self.hass.states.get(user_input[CONF_ZONE]) + return self.async_create_entry( + title=state.name if state else "IRM KMI", + data={CONF_ZONE: user_input[CONF_ZONE], + CONF_STYLE: user_input[CONF_STYLE], + CONF_DARK_MODE: user_input[CONF_DARK_MODE], + CONF_USE_DEPRECATED_FORECAST: user_input[CONF_USE_DEPRECATED_FORECAST]}, + ) return self.async_show_form( step_id="user", + errors=errors, data_schema=vol.Schema({ vol.Required(CONF_ZONE): EntitySelector(EntitySelectorConfig(domain=ZONE_DOMAIN)), diff --git a/custom_components/irm_kmi/const.py b/custom_components/irm_kmi/const.py index 716e7c5..862438c 100644 --- a/custom_components/irm_kmi/const.py +++ b/custom_components/irm_kmi/const.py @@ -57,6 +57,11 @@ OPTION_DEPRECATED_FORECAST_HOURLY ] +REPAIR_SOLUTION: Final = "repair_solution" +REPAIR_OPT_MOVE: Final = "repair_option_move" +REPAIR_OPT_DELETE: Final = "repair_option_delete" +REPAIR_OPTIONS: Final = [REPAIR_OPT_MOVE, REPAIR_OPT_DELETE] + # map ('ww', 'dayNight') tuple from IRM KMI to HA conditions IRM_KMI_TO_HA_CONDITION_MAP: Final = { (0, 'd'): ATTR_CONDITION_SUNNY, diff --git a/custom_components/irm_kmi/coordinator.py b/custom_components/irm_kmi/coordinator.py index 46d4a0a..a70d2b5 100644 --- a/custom_components/irm_kmi/coordinator.py +++ b/custom_components/irm_kmi/coordinator.py @@ -10,13 +10,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_ZONE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import issue_registry from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import (DataUpdateCoordinator, UpdateFailed) from .api import IrmKmiApiClient, IrmKmiApiError -from .const import CONF_DARK_MODE, CONF_STYLE +from .const import CONF_DARK_MODE, CONF_STYLE, DOMAIN from .const import IRM_KMI_TO_HA_CONDITION_MAP as CDT_MAP from .const import (LANGS, OPTION_STYLE_SATELLITE, OUT_OF_BENELUX, STYLE_TO_PARAM_MAP) @@ -39,7 +39,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): # Name of the data. For logging purposes. name="IRM KMI weather", # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(minutes=7), + update_interval=timedelta(seconds=15), ) self._api_client = IrmKmiApiClient(session=async_get_clientsession(hass)) self._zone = get_config_value(entry, CONF_ZONE) @@ -70,17 +70,24 @@ async def _async_update_data(self) -> ProcessedCoordinatorData: raise UpdateFailed(f"Error communicating with API: {err}") if api_data.get('cityName', None) in OUT_OF_BENELUX: - if self.data is None: - error_text = f"Zone '{self._zone}' is out of Benelux and forecast is only available in the Benelux" - _LOGGER.error(error_text) - raise ConfigEntryError(error_text) - else: - # TODO create a repair when this triggers - _LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux." - f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix " - f"this") - disable_from_config(self.hass, self._config_entry) - return ProcessedCoordinatorData() + # TODO create a repair when this triggers + _LOGGER.info(f"Config state: {self._config_entry.state}") + _LOGGER.error(f"The zone {self._zone} is now out of Benelux and forecast is only available in Benelux." + f"Associated device is now disabled. Move the zone back in Benelux and re-enable to fix " + f"this") + disable_from_config(self.hass, self._config_entry) + + issue_registry.async_create_issue( + self.hass, + DOMAIN, + "zone_moved", + is_fixable=True, + severity=issue_registry.IssueSeverity.ERROR, + translation_key='zone_moved', + data={'config_entry_id': self._config_entry.entry_id, 'zone': self._zone}, + translation_placeholders={'zone': self._zone} + ) + return ProcessedCoordinatorData() return await self.process_api_data(api_data) diff --git a/custom_components/irm_kmi/repairs.py b/custom_components/irm_kmi/repairs.py new file mode 100644 index 0000000..7a815f3 --- /dev/null +++ b/custom_components/irm_kmi/repairs.py @@ -0,0 +1,93 @@ +import logging + +import async_timeout +import voluptuous as vol +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig + +from custom_components.irm_kmi import async_reload_entry +from custom_components.irm_kmi.api import IrmKmiApiClient +from custom_components.irm_kmi.const import (OUT_OF_BENELUX, REPAIR_OPT_DELETE, + REPAIR_OPT_MOVE, REPAIR_OPTIONS, + REPAIR_SOLUTION) +from custom_components.irm_kmi.utils import modify_from_config + +_LOGGER = logging.getLogger(__name__) + + +class OutOfBeneluxRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, data: dict): + self._data: dict = data + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + + return await (self.async_step_confirm()) + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + errors = {} + + config_entry = self.hass.config_entries.async_get_entry(self._data['config_entry_id']) + _LOGGER.info(f"State of config entry: {config_entry.state}") + + if user_input is not None: + if user_input[REPAIR_SOLUTION] == REPAIR_OPT_MOVE: + if (zone := self.hass.states.get(self._data['zone'])) is None: + errors[REPAIR_SOLUTION] = "zone_not_exist" + + if not errors: + api_data = {} + try: + async with async_timeout.timeout(10): + api_data = await IrmKmiApiClient( + session=async_get_clientsession(self.hass)).get_forecasts_coord( + {'lat': zone.attributes[ATTR_LATITUDE], + 'long': zone.attributes[ATTR_LONGITUDE]} + ) + except Exception: + errors[REPAIR_SOLUTION] = 'api_error' + + if api_data.get('cityName', None) in OUT_OF_BENELUX: + errors[REPAIR_SOLUTION] = 'out_of_benelux' + + if not errors: + modify_from_config(self.hass, self._data['config_entry_id'], enable=True) + await async_reload_entry(self.hass, config_entry) + + elif user_input[REPAIR_SOLUTION] == REPAIR_OPT_DELETE: + await self.hass.config_entries.async_remove(self._data['config_entry_id']) + else: + errors[REPAIR_SOLUTION] = "invalid_choice" + + if not errors: + return self.async_create_entry(title="", data={}) + + return self.async_show_form( + step_id="confirm", + errors=errors, + description_placeholders={'zone': self._data['zone']}, + data_schema=vol.Schema({ + vol.Required(REPAIR_SOLUTION, default=REPAIR_OPT_MOVE): + SelectSelector(SelectSelectorConfig(options=REPAIR_OPTIONS, + translation_key=REPAIR_SOLUTION)), + })) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + return OutOfBeneluxRepairFlow(data) diff --git a/custom_components/irm_kmi/translations/en.json b/custom_components/irm_kmi/translations/en.json index a6cc089..029ca3f 100644 --- a/custom_components/irm_kmi/translations/en.json +++ b/custom_components/irm_kmi/translations/en.json @@ -31,6 +31,12 @@ "daily_in_deprecated_forecast": "Use for daily forecast", "hourly_in_use_deprecated_forecast": "Use for hourly forecast" } + }, + "repair_solution": { + "options": { + "repair_option_move": "I moved the zone in Benelux", + "repair_option_delete": "Delete that config entry" + } } }, "options": { @@ -47,8 +53,21 @@ }, "issues": { "zone_moved": { - "title": "Zone moved", - "description": "Hey!" + "title": "{zone} is outside of Benelux", + "fix_flow": { + "step": { + "confirm": { + "title": "Title for the confirm step", + "description": "This integration can only get data for location in the Benelux. Move the zone or delete this configuration entry." + } + }, + "error": { + "out_of_benelux": "{zone} is out of Benelux. Move it inside Benelux first.", + "api_error": "Could not get data from the API", + "zone_not_exist": "{zone} does not exist", + "invalid_choice": "The choice is not valid" + } + } } } } diff --git a/requirements.txt b/requirements.txt index 41ab769..3b8d526 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ aiohttp==3.9.1 async_timeout==4.0.3 -homeassistant==2023.12.3 +homeassistant==2024.1.2 voluptuous==0.13.1 pytz==2023.3.post1 svgwrite==1.4.3 \ No newline at end of file diff --git a/requirements_tests.txt b/requirements_tests.txt index a0dabe0..272fe62 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -1,5 +1,6 @@ -homeassistant==2023.12.3 -pytest==7.4.3 -pytest_homeassistant_custom_component==0.13.85 -freezegun==1.2.2 -Pillow==10.1.0 \ No newline at end of file +homeassistant==2024.1.2 +pytest +pytest_homeassistant_custom_component @ git+https://github.com/MatthewFlamm/pytest-homeassistant-custom-component +freezegun +Pillow==10.1.0 +isort \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 5ad0d4f..f18e5be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,8 @@ from pytest_homeassistant_custom_component.common import (MockConfigEntry, load_fixture) -from custom_components.irm_kmi.api import IrmKmiApiParametersError +from custom_components.irm_kmi.api import (IrmKmiApiError, + IrmKmiApiParametersError) from custom_components.irm_kmi.const import ( CONF_DARK_MODE, CONF_STYLE, CONF_USE_DEPRECATED_FORECAST, DOMAIN, OPTION_DEPRECATED_FORECAST_NOT_USED, OPTION_STYLE_STD) @@ -60,6 +61,30 @@ def mock_setup_entry() -> Generator[None, None, None]: yield +@pytest.fixture +def mock_get_forecast_in_benelux(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something valid and in the Benelux""" + with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + return_value={'cityName': 'Brussels'}): + yield + + +@pytest.fixture +def mock_get_forecast_out_benelux(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it returns something outside Benelux""" + with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + return_value={'cityName': "Outside the Benelux (Brussels)"}): + yield + + +@pytest.fixture +def mock_get_forecast_api_error(): + """Mock a call to IrmKmiApiClient.get_forecasts_coord() so that it raises an error""" + with patch("custom_components.irm_kmi.config_flow.IrmKmiApiClient.get_forecasts_coord", + side_effet=IrmKmiApiError): + return + + @pytest.fixture() def mock_irm_kmi_api(request: pytest.FixtureRequest) -> Generator[None, MagicMock, None]: """Return a mocked IrmKmi api client.""" diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7854279..ef1790c 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -19,6 +19,7 @@ async def test_full_user_flow( hass: HomeAssistant, mock_setup_entry: MagicMock, + mock_get_forecast_in_benelux: MagicMock ) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( @@ -43,6 +44,48 @@ async def test_full_user_flow( CONF_USE_DEPRECATED_FORECAST: OPTION_DEPRECATED_FORECAST_NOT_USED} +async def test_config_flow_out_benelux_zone( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_out_benelux: MagicMock +) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ZONE: ENTITY_ID_HOME, + CONF_STYLE: OPTION_STYLE_STD, + CONF_DARK_MODE: False}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "user" + assert CONF_ZONE in result2.get('errors') + + +async def test_config_flow_with_api_error( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_get_forecast_api_error: MagicMock +) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_ZONE: ENTITY_ID_HOME, + CONF_STYLE: OPTION_STYLE_STD, + CONF_DARK_MODE: False}, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("step_id") == "user" + assert 'base' in result2.get('errors') + + async def test_config_entry_migration(hass: HomeAssistant) -> None: """Ensure that config entry migration takes the configuration to the latest version""" entry = MockConfigEntry( diff --git a/tests/test_init.py b/tests/test_init.py index 5ece1a8..27ec08e 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -74,29 +74,3 @@ async def test_config_entry_zone_removed( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY assert "Zone 'zone.castle' not found" in caplog.text - - -async def test_zone_out_of_benelux( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - mock_irm_kmi_api_out_benelux: AsyncMock -) -> None: - """Test the IRM KMI when configuration zone is out of Benelux""" - mock_config_entry = MockConfigEntry( - title="London", - domain=DOMAIN, - data={CONF_ZONE: "zone.london"}, - unique_id="zone.london", - ) - hass.states.async_set( - "zone.london", - 0, - {"latitude": 51.5072, "longitude": 0.1276}, - ) - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR - assert "Zone 'zone.london' is out of Benelux" in caplog.text