Bump stookwijzer to v1.5.1 (#131567)

This commit is contained in:
Franck Nijhof 2024-11-25 21:33:47 +01:00 committed by GitHub
parent 1b62e12261
commit 4a8f3eea69
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 399 additions and 73 deletions

View File

@ -7,8 +7,10 @@ from stookwijzer import Stookwijzer
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant 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] PLATFORMS = [Platform.SENSOR]
@ -16,8 +18,9 @@ PLATFORMS = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Stookwijzer from a config entry.""" """Set up Stookwijzer from a config entry."""
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Stookwijzer( hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Stookwijzer(
entry.data[CONF_LOCATION][CONF_LATITUDE], async_get_clientsession(hass),
entry.data[CONF_LOCATION][CONF_LONGITUDE], entry.data[CONF_LATITUDE],
entry.data[CONF_LONGITUDE],
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True 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): if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
del hass.data[DOMAIN][entry.entry_id] del hass.data[DOMAIN][entry.entry_id]
return unload_ok 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

View File

@ -4,10 +4,12 @@ from __future__ import annotations
from typing import Any from typing import Any
from stookwijzer import Stookwijzer
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE 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 homeassistant.helpers.selector import LocationSelector
from .const import DOMAIN from .const import DOMAIN
@ -16,21 +18,29 @@ from .const import DOMAIN
class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN): class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for Stookwijzer.""" """Config flow for Stookwijzer."""
VERSION = 1 VERSION = 2
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
errors = {}
if user_input is not None: if user_input is not None:
return self.async_create_entry( latitude, longitude = await Stookwijzer.async_transform_coordinates(
title="Stookwijzer", async_get_clientsession(self.hass),
data=user_input, 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( return self.async_show_form(
step_id="user", step_id="user",
errors=errors,
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required( vol.Required(

View File

@ -1,16 +1,7 @@
"""Constants for the Stookwijzer integration.""" """Constants for the Stookwijzer integration."""
from enum import StrEnum
import logging import logging
from typing import Final from typing import Final
DOMAIN: Final = "stookwijzer" DOMAIN: Final = "stookwijzer"
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
class StookwijzerState(StrEnum):
"""Stookwijzer states for sensor entity."""
BLUE = "blauw"
ORANGE = "oranje"
RED = "rood"

View File

@ -17,16 +17,6 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
client: Stookwijzer = hass.data[DOMAIN][entry.entry_id] client: Stookwijzer = hass.data[DOMAIN][entry.entry_id]
last_updated = None
if client.last_updated:
last_updated = client.last_updated.isoformat()
return { return {
"state": client.state, "advice": client.advice,
"last_updated": last_updated,
"lqi": client.lqi,
"windspeed": client.windspeed,
"weather": client.weather,
"concentrations": client.concentrations,
} }

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer", "documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["stookwijzer==1.3.0"] "requirements": ["stookwijzer==1.5.1"]
} }

View File

@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, StookwijzerState from .const import DOMAIN
SCAN_INTERVAL = timedelta(minutes=60) SCAN_INTERVAL = timedelta(minutes=60)
@ -30,37 +30,33 @@ async def async_setup_entry(
class StookwijzerSensor(SensorEntity): class StookwijzerSensor(SensorEntity):
"""Defines a Stookwijzer binary sensor.""" """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_device_class = SensorDeviceClass.ENUM
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_name = None _attr_translation_key = "advice"
_attr_translation_key = "stookwijzer"
def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None: def __init__(self, client: Stookwijzer, entry: ConfigEntry) -> None:
"""Initialize a Stookwijzer device.""" """Initialize a Stookwijzer device."""
self._client = client 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_unique_id = entry.entry_id
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{entry.entry_id}")}, identifiers={(DOMAIN, entry.entry_id)},
name="Stookwijzer", manufacturer="Atlas Leefomgeving",
manufacturer="stookwijzer.nu",
entry_type=DeviceEntryType.SERVICE, 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.""" """Update the data from the Stookwijzer handler."""
self._client.update() await self._client.async_update()
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if entity is available.""" """Return if entity is available."""
return self._client.state is not None return self._client.advice is not None
@property @property
def native_value(self) -> str | None: def native_value(self) -> str | None:
"""Return the state of the device.""" """Return the state of the device."""
if self._client.state is None: return self._client.advice
return None
return StookwijzerState(self._client.state).value

View File

@ -7,17 +7,27 @@
"location": "[%key:common::config_flow::data::location%]" "location": "[%key:common::config_flow::data::location%]"
} }
} }
},
"error": {
"unknown": "[%key:common::config_flow::error::unknown%]"
} }
}, },
"entity": { "entity": {
"sensor": { "sensor": {
"stookwijzer": { "advice": {
"name": "Advice code",
"state": { "state": {
"blauw": "Blue", "code_yellow": "Yellow",
"oranje": "Orange", "code_orange": "Orange",
"rood": "Red" "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"
}
} }
} }

View File

@ -2746,7 +2746,7 @@ steamodd==4.21
stookalert==0.1.4 stookalert==0.1.4
# homeassistant.components.stookwijzer # homeassistant.components.stookwijzer
stookwijzer==1.3.0 stookwijzer==1.5.1
# homeassistant.components.streamlabswater # homeassistant.components.streamlabswater
streamlabswater==1.0.1 streamlabswater==1.0.1

View File

@ -2195,7 +2195,7 @@ steamodd==4.21
stookalert==0.1.4 stookalert==0.1.4
# homeassistant.components.stookwijzer # homeassistant.components.stookwijzer
stookwijzer==1.3.0 stookwijzer==1.5.1
# homeassistant.components.streamlabswater # homeassistant.components.streamlabswater
streamlabswater==1.0.1 streamlabswater==1.0.1

View File

@ -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

View File

@ -0,0 +1,6 @@
# serializer version: 1
# name: test_get_diagnostics
dict({
'advice': 'code_yellow',
})
# ---

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.stookwijzer_advice_code',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'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': <ANY>,
'entity_id': 'sensor.stookwijzer_advice_code',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'code_yellow',
})
# ---

View File

@ -1,6 +1,8 @@
"""Tests for the Stookwijzer config flow.""" """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.components.stookwijzer.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER from homeassistant.config_entries import SOURCE_USER
@ -9,35 +11,65 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType 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.""" """Test the full user configuration flow."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} DOMAIN, context={"source": SOURCE_USER}
) )
assert result.get("type") is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result.get("step_id") == "user" assert result["step_id"] == "user"
assert "flow_id" in result
with patch( result = await hass.config_entries.flow.async_configure(
"homeassistant.components.stookwijzer.async_setup_entry", return_value=True result["flow_id"],
) as mock_setup_entry: user_input={CONF_LOCATION: {CONF_LATITUDE: 1.0, CONF_LONGITUDE: 1.1}},
result2 = 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 result["type"] is FlowResultType.CREATE_ENTRY
assert result2.get("data") == { assert result["title"] == "Stookwijzer"
"location": { assert result["data"] == {
"latitude": 1.0, CONF_LATITUDE: 200000.123456789,
"longitude": 1.1, CONF_LONGITUDE: 450000.123456789,
},
} }
assert len(mock_setup_entry.mock_calls) == 1 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

View File

@ -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
)

View File

@ -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

View File

@ -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)