diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index a714e3bd368..f121c8ab4bb 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -7,8 +7,10 @@ from stookwijzer import Stookwijzer from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN +from .const import DOMAIN, LOGGER PLATFORMS = [Platform.SENSOR] @@ -16,8 +18,9 @@ PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Stookwijzer from a config entry.""" hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Stookwijzer( - entry.data[CONF_LOCATION][CONF_LATITUDE], - entry.data[CONF_LOCATION][CONF_LONGITUDE], + async_get_clientsession(hass), + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -28,3 +31,42 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): del hass.data[DOMAIN][entry.entry_id] return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + LOGGER.debug("Migrating from version %s", entry.version) + + if entry.version == 1: + latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(hass), + entry.data[CONF_LOCATION][CONF_LATITUDE], + entry.data[CONF_LOCATION][CONF_LONGITUDE], + ) + + if not latitude or not longitude: + ir.async_create_issue( + hass, + DOMAIN, + "location_migration_failed", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="location_migration_failed", + translation_placeholders={ + "entry_title": entry.title, + }, + ) + return False + + hass.config_entries.async_update_entry( + entry, + version=2, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + }, + ) + + LOGGER.debug("Migration to version %s successful", entry.version) + + return True diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py index be53ce56390..32b4836763f 100644 --- a/homeassistant/components/stookwijzer/config_flow.py +++ b/homeassistant/components/stookwijzer/config_flow.py @@ -4,10 +4,12 @@ from __future__ import annotations from typing import Any +from stookwijzer import Stookwijzer import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import LocationSelector from .const import DOMAIN @@ -16,21 +18,29 @@ from .const import DOMAIN class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): """Config flow for Stookwijzer.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" - + errors = {} if user_input is not None: - return self.async_create_entry( - title="Stookwijzer", - data=user_input, + latitude, longitude = await Stookwijzer.async_transform_coordinates( + async_get_clientsession(self.hass), + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], ) + if latitude and longitude: + return self.async_create_entry( + title="Stookwijzer", + data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + ) + errors["base"] = "unknown" return self.async_show_form( step_id="user", + errors=errors, data_schema=vol.Schema( { vol.Required( diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index e8cb3d818e6..1b0be86d375 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -1,16 +1,7 @@ """Constants for the Stookwijzer integration.""" -from enum import StrEnum import logging from typing import Final DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) - - -class StookwijzerState(StrEnum): - """Stookwijzer states for sensor entity.""" - - BLUE = "blauw" - ORANGE = "oranje" - RED = "rood" diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py index c7bf4fad14d..59c28482541 100644 --- a/homeassistant/components/stookwijzer/diagnostics.py +++ b/homeassistant/components/stookwijzer/diagnostics.py @@ -17,16 +17,6 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" client: Stookwijzer = hass.data[DOMAIN][entry.entry_id] - - last_updated = None - if client.last_updated: - last_updated = client.last_updated.isoformat() - return { - "state": client.state, - "last_updated": last_updated, - "lqi": client.lqi, - "windspeed": client.windspeed, - "weather": client.weather, - "concentrations": client.concentrations, + "advice": client.advice, } diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json index dbf902b1e1e..3fe16fb3d33 100644 --- a/homeassistant/components/stookwijzer/manifest.json +++ b/homeassistant/components/stookwijzer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/stookwijzer", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["stookwijzer==1.3.0"] + "requirements": ["stookwijzer==1.5.1"] } diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py index b8f9a660598..8489da82c36 100644 --- a/homeassistant/components/stookwijzer/sensor.py +++ b/homeassistant/components/stookwijzer/sensor.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, StookwijzerState +from .const import DOMAIN SCAN_INTERVAL = timedelta(minutes=60) @@ -30,37 +30,33 @@ async def async_setup_entry( class StookwijzerSensor(SensorEntity): """Defines a Stookwijzer binary sensor.""" - _attr_attribution = "Data provided by stookwijzer.nu" + _attr_attribution = "Data provided by atlasleefomgeving.nl" _attr_device_class = SensorDeviceClass.ENUM _attr_has_entity_name = True - _attr_name = None - _attr_translation_key = "stookwijzer" + _attr_translation_key = "advice" def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None: """Initialize a Stookwijzer device.""" self._client = client - self._attr_options = [cls.value for cls in StookwijzerState] + self._attr_options = ["code_yellow", "code_orange", "code_red"] self._attr_unique_id = entry.entry_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"{entry.entry_id}")}, - name="Stookwijzer", - manufacturer="stookwijzer.nu", + identifiers={(DOMAIN, entry.entry_id)}, + manufacturer="Atlas Leefomgeving", entry_type=DeviceEntryType.SERVICE, - configuration_url="https://www.stookwijzer.nu", + configuration_url="https://www.atlasleefomgeving.nl/stookwijzer", ) - def update(self) -> None: + async def async_update(self) -> None: """Update the data from the Stookwijzer handler.""" - self._client.update() + await self._client.async_update() @property def available(self) -> bool: """Return if entity is available.""" - return self._client.state is not None + return self._client.advice is not None @property def native_value(self) -> str | None: """Return the state of the device.""" - if self._client.state is None: - return None - return StookwijzerState(self._client.state).value + return self._client.advice diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index 549673165ec..253b1034567 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -7,17 +7,27 @@ "location": "[%key:common::config_flow::data::location%]" } } + }, + "error": { + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "entity": { "sensor": { - "stookwijzer": { + "advice": { + "name": "Advice code", "state": { - "blauw": "Blue", - "oranje": "Orange", - "rood": "Red" + "code_yellow": "Yellow", + "code_orange": "Orange", + "code_red": "Red" } } } + }, + "issues": { + "location_migration_failed": { + "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", + "title": "Migration of your location failed" + } } } diff --git a/requirements_all.txt b/requirements_all.txt index c8c06c4711a..90aa3077ae8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2746,7 +2746,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.3.0 +stookwijzer==1.5.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb83d2adff2..7ffd9d02f39 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2195,7 +2195,7 @@ steamodd==4.21 stookalert==0.1.4 # homeassistant.components.stookwijzer -stookwijzer==1.3.0 +stookwijzer==1.5.1 # homeassistant.components.streamlabswater streamlabswater==1.0.1 diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py new file mode 100644 index 00000000000..974b6ef5643 --- /dev/null +++ b/tests/components/stookwijzer/conftest.py @@ -0,0 +1,92 @@ +"""Fixtures for Stookwijzer integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.stookwijzer.const import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Stookwijzer", + domain=DOMAIN, + data={ + CONF_LATITUDE: 200000.1234567890, + CONF_LONGITUDE: 450000.1234567890, + }, + version=2, + entry_id="12345", + ) + + +@pytest.fixture +def mock_v1_config_entry() -> MockConfigEntry: + """Return the default mocked version 1 config entry.""" + return MockConfigEntry( + title="Stookwijzer", + domain=DOMAIN, + data={ + CONF_LOCATION: { + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 1.1, + }, + }, + version=1, + entry_id="12345", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.stookwijzer.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_stookwijzer() -> Generator[MagicMock]: + """Return a mocked Stookwijzer client.""" + with ( + patch( + "homeassistant.components.stookwijzer.Stookwijzer", + autospec=True, + ) as stookwijzer_mock, + patch( + "homeassistant.components.stookwijzer.config_flow.Stookwijzer", + new=stookwijzer_mock, + ), + ): + stookwijzer_mock.async_transform_coordinates.return_value = ( + 200000.123456789, + 450000.123456789, + ) + + client = stookwijzer_mock.return_value + client.advice = "code_yellow" + + yield stookwijzer_mock + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_stookwijzer: MagicMock, +) -> MockConfigEntry: + """Set up the Stookwijzer integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/stookwijzer/snapshots/test_diagnostics.ambr b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..f2b7815f753 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_diagnostics.ambr @@ -0,0 +1,6 @@ +# serializer version: 1 +# name: test_get_diagnostics + dict({ + 'advice': 'code_yellow', + }) +# --- diff --git a/tests/components/stookwijzer/snapshots/test_sensor.ambr b/tests/components/stookwijzer/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bd7fcc6fef5 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_sensor.ambr @@ -0,0 +1,60 @@ +# serializer version: 1 +# name: test_entities[sensor.stookwijzer_advice_code-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'code_yellow', + 'code_orange', + 'code_red', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.stookwijzer_advice_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Advice code', + 'platform': 'stookwijzer', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'advice', + 'unique_id': '12345', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.stookwijzer_advice_code-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by atlasleefomgeving.nl', + 'device_class': 'enum', + 'friendly_name': 'Stookwijzer Advice code', + 'options': list([ + 'code_yellow', + 'code_orange', + 'code_red', + ]), + }), + 'context': , + 'entity_id': 'sensor.stookwijzer_advice_code', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'code_yellow', + }) +# --- diff --git a/tests/components/stookwijzer/test_config_flow.py b/tests/components/stookwijzer/test_config_flow.py index 732e8abfc98..6dddf83c27a 100644 --- a/tests/components/stookwijzer/test_config_flow.py +++ b/tests/components/stookwijzer/test_config_flow.py @@ -1,6 +1,8 @@ """Tests for the Stookwijzer config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock + +import pytest from homeassistant.components.stookwijzer.const import DOMAIN from homeassistant.config_entries import SOURCE_USER @@ -9,35 +11,65 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -async def test_full_user_flow(hass: HomeAssistant) -> None: +async def test_full_user_flow( + hass: HomeAssistant, + mock_stookwijzer: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: """Test the full user configuration flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - assert "flow_id" in result + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" - with patch( - "homeassistant.components.stookwijzer.async_setup_entry", return_value=True - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_LOCATION: { - CONF_LATITUDE: 1.0, - CONF_LONGITUDE: 1.1, - } - }, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {CONF_LATITUDE: 1.0, CONF_LONGITUDE: 1.1}}, + ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("data") == { - "location": { - "latitude": 1.0, - "longitude": 1.1, - }, + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Stookwijzer" + assert result["data"] == { + CONF_LATITUDE: 200000.123456789, + CONF_LONGITUDE: 450000.123456789, } assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_stookwijzer.async_transform_coordinates.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_connection_error( + hass: HomeAssistant, + mock_stookwijzer: MagicMock, +) -> None: + """Test user configuration flow while connection fails.""" + original_return_value = mock_stookwijzer.async_transform_coordinates.return_value + mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {CONF_LATITUDE: 1.0, CONF_LONGITUDE: 1.1}}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + # Ensure we can continue the flow, when it now works + mock_stookwijzer.async_transform_coordinates.return_value = original_return_value + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_LOCATION: {CONF_LATITUDE: 1.0, CONF_LONGITUDE: 1.1}}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/stookwijzer/test_diagnostics.py b/tests/components/stookwijzer/test_diagnostics.py new file mode 100644 index 00000000000..f40165020c1 --- /dev/null +++ b/tests/components/stookwijzer/test_diagnostics.py @@ -0,0 +1,22 @@ +"""Test the Stookwijzer diagnostics.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_get_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Stookwijzer diagnostics.""" + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) diff --git a/tests/components/stookwijzer/test_init.py b/tests/components/stookwijzer/test_init.py new file mode 100644 index 00000000000..1a84954c21a --- /dev/null +++ b/tests/components/stookwijzer/test_init.py @@ -0,0 +1,55 @@ +"""Test the Stookwijzer init.""" + +from unittest.mock import MagicMock + +from homeassistant.components.stookwijzer.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_migrate_entry( + hass: HomeAssistant, + mock_v1_config_entry: MockConfigEntry, + mock_stookwijzer: MagicMock, +) -> None: + """Test successful migration of entry data.""" + assert mock_v1_config_entry.version == 1 + + mock_v1_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_v1_config_entry.state is ConfigEntryState.LOADED + assert len(mock_stookwijzer.async_transform_coordinates.mock_calls) == 1 + + assert mock_v1_config_entry.version == 2 + assert mock_v1_config_entry.data == { + CONF_LATITUDE: 200000.123456789, + CONF_LONGITUDE: 450000.123456789, + } + + +async def test_entry_migration_failure( + hass: HomeAssistant, + mock_v1_config_entry: MockConfigEntry, + mock_stookwijzer: MagicMock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test successful migration of entry data.""" + assert mock_v1_config_entry.version == 1 + + # Failed getting the transformed coordinates + mock_stookwijzer.async_transform_coordinates.return_value = (None, None) + + mock_v1_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_v1_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_v1_config_entry.state is ConfigEntryState.MIGRATION_ERROR + assert issue_registry.async_get_issue(DOMAIN, "location_migration_failed") + + assert len(mock_stookwijzer.async_transform_coordinates.mock_calls) == 1 diff --git a/tests/components/stookwijzer/test_sensor.py b/tests/components/stookwijzer/test_sensor.py new file mode 100644 index 00000000000..10eeef72d74 --- /dev/null +++ b/tests/components/stookwijzer/test_sensor.py @@ -0,0 +1,20 @@ +"""Tests for the Stookwijzer sensor platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("init_integration") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Stookwijzer entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)