diff --git a/.strict-typing b/.strict-typing index 584ccc5ee0a49d..28f484b3334eea 100644 --- a/.strict-typing +++ b/.strict-typing @@ -244,6 +244,7 @@ homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* +homeassistant.components.imgw_pib.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* diff --git a/CODEOWNERS b/CODEOWNERS index fdea411d208391..f1fb578155b9f8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -650,6 +650,8 @@ build.json @home-assistant/supervisor /tests/components/image_upload/ @home-assistant/core /homeassistant/components/imap/ @jbouwh /tests/components/imap/ @jbouwh +/homeassistant/components/imgw_pib/ @bieniu +/tests/components/imgw_pib/ @bieniu /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @zxdavb diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py new file mode 100644 index 00000000000000..f3dd66eb23d678 --- /dev/null +++ b/homeassistant/components/imgw_pib/__init__.py @@ -0,0 +1,62 @@ +"""The IMGW-PIB integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from aiohttp import ClientError +from imgw_pib import ImgwPib +from imgw_pib.exceptions import ApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_STATION_ID +from .coordinator import ImgwPibDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + +ImgwPibConfigEntry = ConfigEntry["ImgwPibData"] + + +@dataclass +class ImgwPibData: + """Data for the IMGW-PIB integration.""" + + coordinator: ImgwPibDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> bool: + """Set up IMGW-PIB from a config entry.""" + station_id: str = entry.data[CONF_STATION_ID] + + _LOGGER.debug("Using hydrological station ID: %s", station_id) + + client_session = async_get_clientsession(hass) + + try: + imgwpib = await ImgwPib.create( + client_session, hydrological_station_id=station_id + ) + except (ClientError, TimeoutError, ApiError) as err: + raise ConfigEntryNotReady from err + + coordinator = ImgwPibDataUpdateCoordinator(hass, imgwpib, station_id) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = ImgwPibData(coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py new file mode 100644 index 00000000000000..558528fcbef4e6 --- /dev/null +++ b/homeassistant/components/imgw_pib/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for IMGW-PIB integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientError +from imgw_pib import ImgwPib +from imgw_pib.exceptions import ApiError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_STATION_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ImgwPibFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for IMGW-PIB.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + client_session = async_get_clientsession(self.hass) + + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + + await self.async_set_unique_id(station_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + + try: + imgwpib = await ImgwPib.create( + client_session, hydrological_station_id=station_id + ) + hydrological_data = await imgwpib.get_hydrological_data() + except (ClientError, TimeoutError, ApiError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + title = f"{hydrological_data.river} ({hydrological_data.station})" + return self.async_create_entry(title=title, data=user_input) + + try: + imgwpib = await ImgwPib.create(client_session) + await imgwpib.update_hydrological_stations() + except (ClientError, TimeoutError, ApiError): + return self.async_abort(reason="cannot_connect") + + options: list[SelectOptionDict] = [ + SelectOptionDict(value=station_id, label=station_name) + for station_id, station_name in imgwpib.hydrological_stations.items() + ] + + schema: vol.Schema = vol.Schema( + { + vol.Required(CONF_STATION_ID): SelectSelector( + SelectSelectorConfig( + options=options, + multiple=False, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + ), + ) + } + ) + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imgw_pib/const.py b/homeassistant/components/imgw_pib/const.py new file mode 100644 index 00000000000000..41782ea059a266 --- /dev/null +++ b/homeassistant/components/imgw_pib/const.py @@ -0,0 +1,11 @@ +"""Constants for the IMGW-PIB integration.""" + +from datetime import timedelta + +DOMAIN = "imgw_pib" + +ATTRIBUTION = "Data provided by IMGW-PIB" + +CONF_STATION_ID = "station_id" + +UPDATE_INTERVAL = timedelta(minutes=30) diff --git a/homeassistant/components/imgw_pib/coordinator.py b/homeassistant/components/imgw_pib/coordinator.py new file mode 100644 index 00000000000000..77a58001a6f98b --- /dev/null +++ b/homeassistant/components/imgw_pib/coordinator.py @@ -0,0 +1,43 @@ +"""Data Update Coordinator for IMGW-PIB integration.""" + +import logging + +from imgw_pib import ApiError, HydrologicalData, ImgwPib + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class ImgwPibDataUpdateCoordinator(DataUpdateCoordinator[HydrologicalData]): + """Class to manage fetching IMGW-PIB data API.""" + + def __init__( + self, + hass: HomeAssistant, + imgwpib: ImgwPib, + station_id: str, + ) -> None: + """Initialize.""" + self.imgwpib = imgwpib + self.station_id = station_id + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, station_id)}, + manufacturer="IMGW-PIB", + name=f"{imgwpib.hydrological_stations[station_id]}", + configuration_url=f"https://hydro.imgw.pl/#/station/hydro/{station_id}", + ) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + async def _async_update_data(self) -> HydrologicalData: + """Update data via internal method.""" + try: + return await self.imgwpib.get_hydrological_data() + except ApiError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json new file mode 100644 index 00000000000000..29aa19a4b56e42 --- /dev/null +++ b/homeassistant/components/imgw_pib/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "water_level": { + "default": "mdi:waves" + }, + "water_temperature": { + "default": "mdi:thermometer-water" + } + } + } +} diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json new file mode 100644 index 00000000000000..63f6146be84785 --- /dev/null +++ b/homeassistant/components/imgw_pib/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "imgw_pib", + "name": "IMGW-PIB", + "codeowners": ["@bieniu"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/imgw_pib", + "iot_class": "cloud_polling", + "requirements": ["imgw_pib==1.0.0"] +} diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py new file mode 100644 index 00000000000000..1df651faa52c73 --- /dev/null +++ b/homeassistant/components/imgw_pib/sensor.py @@ -0,0 +1,97 @@ +"""IMGW-PIB sensor platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from imgw_pib.model import HydrologicalData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ImgwPibConfigEntry +from .const import ATTRIBUTION +from .coordinator import ImgwPibDataUpdateCoordinator + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class ImgwPibSensorEntityDescription(SensorEntityDescription): + """IMGW-PIB sensor entity description.""" + + value: Callable[[HydrologicalData], StateType] + + +SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="water_level", + translation_key="water_level", + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value=lambda data: data.water_level.value, + ), + ImgwPibSensorEntityDescription( + key="water_temperature", + translation_key="water_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value=lambda data: data.water_temperature.value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ImgwPibConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a IMGW-PIB sensor entity from a config_entry.""" + coordinator = entry.runtime_data.coordinator + + async_add_entities( + ImgwPibSensorEntity(coordinator, description) + for description in SENSOR_TYPES + if getattr(coordinator.data, description.key).value is not None + ) + + +class ImgwPibSensorEntity( + CoordinatorEntity[ImgwPibDataUpdateCoordinator], SensorEntity +): + """Define IMGW-PIB sensor entity.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + entity_description: ImgwPibSensorEntityDescription + + def __init__( + self, + coordinator: ImgwPibDataUpdateCoordinator, + description: ImgwPibSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.station_id}_{description.key}" + self._attr_device_info = coordinator.device_info + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json new file mode 100644 index 00000000000000..9a17dcf70878fe --- /dev/null +++ b/homeassistant/components/imgw_pib/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "station_id": "Hydrological station" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "Failed to connect" + } + }, + "entity": { + "sensor": { + "water_level": { + "name": "Water level" + }, + "water_temperature": { + "name": "Water temperature" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6f6ce2379049c9..301715ad1110d2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -251,6 +251,7 @@ "idasen_desk", "ifttt", "imap", + "imgw_pib", "improv_ble", "inkbird", "insteon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e6a103989d167d..e1365820bf4517 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2782,6 +2782,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "imgw_pib": { + "name": "IMGW-PIB", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "improv_ble": { "name": "Improv via BLE", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 611dd176fbf29b..08e4bcc0e4f1af 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2202,6 +2202,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.imgw_pib.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8c7caa60eef70e..69c1ee62b97839 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1136,6 +1136,9 @@ iglo==1.2.7 # homeassistant.components.ihc ihcsdk==2.8.5 +# homeassistant.components.imgw_pib +imgw_pib==1.0.0 + # homeassistant.components.incomfort incomfort-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c06ed21f0669c1..cb9c91037b04de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -923,6 +923,9 @@ idasen-ha==2.5.1 # homeassistant.components.network ifaddr==0.2.0 +# homeassistant.components.imgw_pib +imgw_pib==1.0.0 + # homeassistant.components.influxdb influxdb-client==1.24.0 diff --git a/tests/components/imgw_pib/__init__.py b/tests/components/imgw_pib/__init__.py new file mode 100644 index 00000000000000..c684b596949f93 --- /dev/null +++ b/tests/components/imgw_pib/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the IMGW-PIB integration.""" + +from tests.common import MockConfigEntry + + +async def init_integration(hass, config_entry: MockConfigEntry) -> MockConfigEntry: + """Set up the IMGW-PIB integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py new file mode 100644 index 00000000000000..b22b8b68661709 --- /dev/null +++ b/tests/components/imgw_pib/conftest.py @@ -0,0 +1,67 @@ +"""Common fixtures for the IMGW-PIB tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from imgw_pib import HydrologicalData, SensorData +import pytest + +from homeassistant.components.imgw_pib.const import DOMAIN + +from tests.common import MockConfigEntry + +HYDROLOGICAL_DATA = HydrologicalData( + station="Station Name", + river="River Name", + station_id="123", + water_level=SensorData(name="Water Level", value=526.0), + flood_alarm_level=SensorData(name="Flood Alarm Level", value=630.0), + flood_warning_level=SensorData(name="Flood Warning Level", value=590.0), + water_temperature=SensorData(name="Water Temperature", value=10.8), + flood_alarm=False, + flood_warning=False, + water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), + water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.imgw_pib.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_imgw_pib_client() -> Generator[AsyncMock, None, None]: + """Mock a ImgwPib client.""" + with ( + patch( + "homeassistant.components.imgw_pib.ImgwPib", autospec=True + ) as mock_client, + patch( + "homeassistant.components.imgw_pib.config_flow.ImgwPib", + new=mock_client, + ), + ): + client = mock_client.create.return_value + client.get_hydrological_data.return_value = HYDROLOGICAL_DATA + client.hydrological_stations = {"123": "River Name (Station Name)"} + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="River Name (Station Name)", + unique_id="123", + data={ + "station_id": "123", + }, + ) diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..0bce7c96d7c86f --- /dev/null +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -0,0 +1,221 @@ +# serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': '123_water_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'distance', + 'friendly_name': 'River Name (Station Name) Water level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '526.0', + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '123_water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'temperature', + 'friendly_name': 'River Name (Station Name) Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.8', + }) +# --- +# name: test_sensor[sensor.station_name_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_name_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': '123_water_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.station_name_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'distance', + 'friendly_name': 'Station Name Water level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_name_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '526.0', + }) +# --- +# name: test_sensor[sensor.station_name_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_name_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '123_water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.station_name_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'temperature', + 'friendly_name': 'Station Name Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_name_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.8', + }) +# --- diff --git a/tests/components/imgw_pib/test_config_flow.py b/tests/components/imgw_pib/test_config_flow.py new file mode 100644 index 00000000000000..ac26ed4771c101 --- /dev/null +++ b/tests/components/imgw_pib/test_config_flow.py @@ -0,0 +1,96 @@ +"""Test the IMGW-PIB config flow.""" + +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from imgw_pib.exceptions import ApiError +import pytest + +from homeassistant.components.imgw_pib.const import CONF_STATION_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_imgw_pib_client: AsyncMock +) -> None: + """Test that the user step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STATION_ID: "123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "River Name (Station Name)" + assert result["data"] == {CONF_STATION_ID: "123"} + assert result["context"]["unique_id"] == "123" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("exc", [ApiError("API Error"), ClientError, TimeoutError]) +async def test_form_no_station_list( + hass: HomeAssistant, exc: Exception, mock_imgw_pib_client: AsyncMock +) -> None: + """Test aborting the flow when we cannot get the list of hydrological stations.""" + mock_imgw_pib_client.update_hydrological_stations.side_effect = exc + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (Exception, "unknown"), + (ApiError("API Error"), "cannot_connect"), + (ClientError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + ], +) +async def test_form_with_exceptions( + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_setup_entry: AsyncMock, + mock_imgw_pib_client: AsyncMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_imgw_pib_client.get_hydrological_data.side_effect = exc + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STATION_ID: "123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + mock_imgw_pib_client.get_hydrological_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STATION_ID: "123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "River Name (Station Name)" + assert result["data"] == {CONF_STATION_ID: "123"} + assert result["context"]["unique_id"] == "123" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py new file mode 100644 index 00000000000000..17c80891b1e72a --- /dev/null +++ b/tests/components/imgw_pib/test_init.py @@ -0,0 +1,44 @@ +"""Test init of IMGW-PIB integration.""" + +from unittest.mock import AsyncMock + +from imgw_pib import ApiError + +from homeassistant.components.imgw_pib.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_config_not_ready( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test for setup failure if the connection to the service fails.""" + mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") + + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful unload of entry.""" + await init_integration(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py new file mode 100644 index 00000000000000..2d17f7246fc079 --- /dev/null +++ b/tests/components/imgw_pib/test_sensor.py @@ -0,0 +1,65 @@ +"""Test the IMGW-PIB sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from imgw_pib import ApiError +from syrupy import SnapshotAssertion + +from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "sensor.river_name_station_name_water_level" + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_imgw_pib_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the sensor.""" + with patch("homeassistant.components.imgw_pib.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_imgw_pib_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure that we mark the entities unavailable correctly when service is offline.""" + await init_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "526.0" + + mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_imgw_pib_client.get_hydrological_data.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "526.0"