Modernize ipma weather (#98062)

* Modernize ipma weather

* Add test snapshots

* Don't include forecast mode in weather entity unique_id for new config entries

* Remove old migration code

* Remove outdated test
This commit is contained in:
Erik Montnemery 2023-08-12 15:15:09 +02:00 committed by GitHub
parent be9afd7eae
commit ae8f9dcb77
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 282 additions and 104 deletions

View File

@ -2,10 +2,10 @@
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from .const import DOMAIN, FORECAST_MODE, HOME_LOCATION_NAME from .const import DOMAIN, HOME_LOCATION_NAME
class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@ -47,7 +47,6 @@ class IpmaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
vol.Required(CONF_NAME, default=name): str, vol.Required(CONF_NAME, default=name): str,
vol.Required(CONF_LATITUDE, default=latitude): cv.latitude, vol.Required(CONF_LATITUDE, default=latitude): cv.latitude,
vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude, vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude,
vol.Required(CONF_MODE, default="daily"): vol.In(FORECAST_MODE),
} }
), ),
errors=self._errors, errors=self._errors,

View File

@ -49,6 +49,4 @@ CONDITION_CLASSES = {
ATTR_CONDITION_CLEAR_NIGHT: [-1], ATTR_CONDITION_CLEAR_NIGHT: [-1],
} }
FORECAST_MODE = ["hourly", "daily"]
ATTRIBUTION = "Instituto Português do Mar e Atmosfera" ATTRIBUTION = "Instituto Português do Mar e Atmosfera"

View File

@ -1,11 +1,14 @@
"""Support for IPMA weather service.""" """Support for IPMA weather service."""
from __future__ import annotations from __future__ import annotations
import asyncio
import contextlib
import logging import logging
from typing import Literal
import async_timeout import async_timeout
from pyipma.api import IPMA_API from pyipma.api import IPMA_API
from pyipma.forecast import Forecast from pyipma.forecast import Forecast as IPMAForecast
from pyipma.location import Location from pyipma.location import Location
from homeassistant.components.weather import ( from homeassistant.components.weather import (
@ -16,7 +19,9 @@ from homeassistant.components.weather import (
ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME, ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_BEARING,
Forecast,
WeatherEntity, WeatherEntity,
WeatherEntityFeature,
) )
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
@ -26,8 +31,7 @@ from homeassistant.const import (
UnitOfSpeed, UnitOfSpeed,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.sun import is_up from homeassistant.helpers.sun import is_up
from homeassistant.util import Throttle from homeassistant.util import Throttle
@ -53,39 +57,19 @@ async def async_setup_entry(
"""Add a weather entity from a config_entry.""" """Add a weather entity from a config_entry."""
api = hass.data[DOMAIN][config_entry.entry_id][DATA_API] api = hass.data[DOMAIN][config_entry.entry_id][DATA_API]
location = hass.data[DOMAIN][config_entry.entry_id][DATA_LOCATION] location = hass.data[DOMAIN][config_entry.entry_id][DATA_LOCATION]
mode = config_entry.data[CONF_MODE]
# Migrate old unique_id
@callback
def _async_migrator(entity_entry: er.RegistryEntry):
# Reject if new unique_id
if entity_entry.unique_id.count(",") == 2:
return None
new_unique_id = (
f"{location.station_latitude}, {location.station_longitude}, {mode}"
)
_LOGGER.info(
"Migrating unique_id from [%s] to [%s]",
entity_entry.unique_id,
new_unique_id,
)
return {"new_unique_id": new_unique_id}
await er.async_migrate_entries(hass, config_entry.entry_id, _async_migrator)
async_add_entities([IPMAWeather(location, api, config_entry.data)], True) async_add_entities([IPMAWeather(location, api, config_entry.data)], True)
class IPMAWeather(WeatherEntity, IPMADevice): class IPMAWeather(WeatherEntity, IPMADevice):
"""Representation of a weather condition.""" """Representation of a weather condition."""
_attr_attribution = ATTRIBUTION
_attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR
_attr_supported_features = (
_attr_attribution = ATTRIBUTION WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
def __init__(self, location: Location, api: IPMA_API, config) -> None: def __init__(self, location: Location, api: IPMA_API, config) -> None:
"""Initialise the platform with a data instance and station name.""" """Initialise the platform with a data instance and station name."""
@ -95,25 +79,35 @@ class IPMAWeather(WeatherEntity, IPMADevice):
self._mode = config.get(CONF_MODE) self._mode = config.get(CONF_MODE)
self._period = 1 if config.get(CONF_MODE) == "hourly" else 24 self._period = 1 if config.get(CONF_MODE) == "hourly" else 24
self._observation = None self._observation = None
self._forecast: list[Forecast] = [] self._daily_forecast: list[IPMAForecast] | None = None
self._hourly_forecast: list[IPMAForecast] | None = None
if self._mode is not None:
self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}" self._attr_unique_id = f"{self._location.station_latitude}, {self._location.station_longitude}, {self._mode}"
else:
self._attr_unique_id = (
f"{self._location.station_latitude}, {self._location.station_longitude}"
)
@Throttle(MIN_TIME_BETWEEN_UPDATES) @Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None: async def async_update(self) -> None:
"""Update Condition and Forecast.""" """Update Condition and Forecast."""
async with async_timeout.timeout(10): async with async_timeout.timeout(10):
new_observation = await self._location.observation(self._api) new_observation = await self._location.observation(self._api)
new_forecast = await self._location.forecast(self._api, self._period)
if new_observation: if new_observation:
self._observation = new_observation self._observation = new_observation
else: else:
_LOGGER.warning("Could not update weather observation") _LOGGER.warning("Could not update weather observation")
if new_forecast: if self._period == 24 or self._forecast_listeners["daily"]:
self._forecast = new_forecast await self._update_forecast("daily", 24, True)
else: else:
_LOGGER.warning("Could not update weather forecast") self._daily_forecast = None
if self._period == 1 or self._forecast_listeners["hourly"]:
await self._update_forecast("hourly", 1, True)
else:
self._hourly_forecast = None
_LOGGER.debug( _LOGGER.debug(
"Updated location %s based on %s, current observation %s", "Updated location %s based on %s, current observation %s",
@ -122,6 +116,21 @@ class IPMAWeather(WeatherEntity, IPMADevice):
self._observation, self._observation,
) )
async def _update_forecast(
self,
forecast_type: Literal["daily", "hourly"],
period: int,
update_listeners: bool,
) -> None:
"""Update weather forecast."""
new_forecast = await self._location.forecast(self._api, period)
if new_forecast:
setattr(self, f"_{forecast_type}_forecast", new_forecast)
if update_listeners:
await self.async_update_listeners((forecast_type,))
else:
_LOGGER.warning("Could not update %s weather forecast", forecast_type)
def _condition_conversion(self, identifier, forecast_dt): def _condition_conversion(self, identifier, forecast_dt):
"""Convert from IPMA weather_type id to HA.""" """Convert from IPMA weather_type id to HA."""
if identifier == 1 and not is_up(self.hass, forecast_dt): if identifier == 1 and not is_up(self.hass, forecast_dt):
@ -135,10 +144,12 @@ class IPMAWeather(WeatherEntity, IPMADevice):
@property @property
def condition(self): def condition(self):
"""Return the current condition.""" """Return the current condition."""
if not self._forecast: forecast = self._hourly_forecast or self._daily_forecast
if not forecast:
return return
return self._condition_conversion(self._forecast[0].weather_type.id, None) return self._condition_conversion(forecast[0].weather_type.id, None)
@property @property
def native_temperature(self): def native_temperature(self):
@ -180,10 +191,9 @@ class IPMAWeather(WeatherEntity, IPMADevice):
return self._observation.wind_direction return self._observation.wind_direction
@property def _forecast(self, forecast: list[IPMAForecast] | None) -> list[Forecast]:
def forecast(self):
"""Return the forecast array.""" """Return the forecast array."""
if not self._forecast: if not forecast:
return [] return []
return [ return [
@ -198,5 +208,32 @@ class IPMAWeather(WeatherEntity, IPMADevice):
ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength,
ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, ATTR_FORECAST_WIND_BEARING: data_in.wind_direction,
} }
for data_in in self._forecast for data_in in forecast
] ]
@property
def forecast(self) -> list[Forecast]:
"""Return the forecast array."""
return self._forecast(
self._hourly_forecast if self._period == 1 else self._daily_forecast
)
async def _try_update_forecast(
self,
forecast_type: Literal["daily", "hourly"],
period: int,
) -> None:
"""Try to update weather forecast."""
with contextlib.suppress(asyncio.TimeoutError):
async with async_timeout.timeout(10):
await self._update_forecast(forecast_type, period, False)
async def async_forecast_daily(self) -> list[Forecast]:
"""Return the daily forecast in native units."""
await self._try_update_forecast("daily", 24)
return self._forecast(self._daily_forecast)
async def async_forecast_hourly(self) -> list[Forecast]:
"""Return the hourly forecast in native units."""
await self._try_update_forecast("hourly", 1)
return self._forecast(self._hourly_forecast)

View File

@ -0,0 +1,104 @@
# serializer version: 1
# name: test_forecast_service
dict({
'forecast': list([
dict({
'condition': 'rainy',
'datetime': datetime.datetime(2020, 1, 16, 0, 0),
'precipitation_probability': '100.0',
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
'wind_speed': 10.0,
}),
]),
})
# ---
# name: test_forecast_service.1
dict({
'forecast': list([
dict({
'condition': 'rainy',
'datetime': datetime.datetime(2020, 1, 15, 1, 0, tzinfo=datetime.timezone.utc),
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
dict({
'condition': 'clear-night',
'datetime': datetime.datetime(2020, 1, 15, 2, 0, tzinfo=datetime.timezone.utc),
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
]),
})
# ---
# name: test_forecast_subscription[daily]
list([
dict({
'condition': 'rainy',
'datetime': '2020-01-16T00:00:00',
'precipitation_probability': '100.0',
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
'wind_speed': 10.0,
}),
])
# ---
# name: test_forecast_subscription[daily].1
list([
dict({
'condition': 'rainy',
'datetime': '2020-01-16T00:00:00',
'precipitation_probability': '100.0',
'temperature': 16.2,
'templow': 10.6,
'wind_bearing': 'S',
'wind_speed': 10.0,
}),
])
# ---
# name: test_forecast_subscription[hourly]
list([
dict({
'condition': 'rainy',
'datetime': '2020-01-15T01:00:00+00:00',
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
dict({
'condition': 'clear-night',
'datetime': '2020-01-15T02:00:00+00:00',
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
])
# ---
# name: test_forecast_subscription[hourly].1
list([
dict({
'condition': 'rainy',
'datetime': '2020-01-15T01:00:00+00:00',
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
dict({
'condition': 'clear-night',
'datetime': '2020-01-15T02:00:00+00:00',
'precipitation_probability': 80.0,
'temperature': 12.0,
'wind_bearing': 'S',
'wind_speed': 32.7,
}),
])
# ---

View File

@ -1,15 +1,9 @@
"""Tests for IPMA config flow.""" """Tests for IPMA config flow."""
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from homeassistant.components.ipma import DOMAIN, config_flow from homeassistant.components.ipma import config_flow
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from . import MockLocation
from tests.common import MockConfigEntry, mock_registry
async def test_show_config_form() -> None: async def test_show_config_form() -> None:
@ -120,53 +114,3 @@ async def test_flow_entry_config_entry_already_exists() -> None:
assert len(config_form.mock_calls) == 1 assert len(config_form.mock_calls) == 1
assert len(config_entries.mock_calls) == 1 assert len(config_entries.mock_calls) == 1
assert len(flow._errors) == 1 assert len(flow._errors) == 1
async def test_config_entry_migration(hass: HomeAssistant) -> None:
"""Tests config entry without mode in unique_id can be migrated."""
ipma_entry = MockConfigEntry(
domain=DOMAIN,
title="Home",
data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "daily"},
)
ipma_entry.add_to_hass(hass)
ipma_entry2 = MockConfigEntry(
domain=DOMAIN,
title="Home",
data={CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_MODE: "hourly"},
)
ipma_entry2.add_to_hass(hass)
mock_registry(
hass,
{
"weather.hometown": er.RegistryEntry(
entity_id="weather.hometown",
unique_id="0, 0",
platform="ipma",
config_entry_id=ipma_entry.entry_id,
),
"weather.hometown_2": er.RegistryEntry(
entity_id="weather.hometown_2",
unique_id="0, 0, hourly",
platform="ipma",
config_entry_id=ipma_entry.entry_id,
),
},
)
with patch(
"pyipma.location.Location.get",
return_value=MockLocation(),
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
ent_reg = er.async_get(hass)
weather_home = ent_reg.async_get("weather.hometown")
assert weather_home.unique_id == "0, 0, daily"
weather_home2 = ent_reg.async_get("weather.hometown_2")
assert weather_home2.unique_id == "0, 0, hourly"

View File

@ -1,9 +1,12 @@
"""The tests for the IPMA weather component.""" """The tests for the IPMA weather component."""
from datetime import datetime import datetime
from unittest.mock import patch from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.ipma.const import MIN_TIME_BETWEEN_UPDATES
from homeassistant.components.weather import ( from homeassistant.components.weather import (
ATTR_FORECAST, ATTR_FORECAST,
ATTR_FORECAST_CONDITION, ATTR_FORECAST_CONDITION,
@ -18,6 +21,8 @@ from homeassistant.components.weather import (
ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_TEMPERATURE,
ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_BEARING,
ATTR_WEATHER_WIND_SPEED, ATTR_WEATHER_WIND_SPEED,
DOMAIN as WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
) )
from homeassistant.const import STATE_UNKNOWN from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -25,6 +30,7 @@ from homeassistant.core import HomeAssistant
from . import MockLocation from . import MockLocation
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
from tests.typing import WebSocketGenerator
TEST_CONFIG = { TEST_CONFIG = {
"name": "HomeTown", "name": "HomeTown",
@ -91,7 +97,7 @@ async def test_daily_forecast(hass: HomeAssistant) -> None:
assert state.state == "rainy" assert state.state == "rainy"
forecast = state.attributes.get(ATTR_FORECAST)[0] forecast = state.attributes.get(ATTR_FORECAST)[0]
assert forecast.get(ATTR_FORECAST_TIME) == datetime(2020, 1, 16, 0, 0, 0) assert forecast.get(ATTR_FORECAST_TIME) == datetime.datetime(2020, 1, 16, 0, 0, 0)
assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy" assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy"
assert forecast.get(ATTR_FORECAST_TEMP) == 16.2 assert forecast.get(ATTR_FORECAST_TEMP) == 16.2
assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6 assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6
@ -144,3 +150,93 @@ async def test_failed_get_observation_forecast(hass: HomeAssistant) -> None:
assert data.get(ATTR_WEATHER_WIND_SPEED) is None assert data.get(ATTR_WEATHER_WIND_SPEED) is None
assert data.get(ATTR_WEATHER_WIND_BEARING) is None assert data.get(ATTR_WEATHER_WIND_BEARING) is None
assert state.attributes.get("friendly_name") == "HomeTown" assert state.attributes.get("friendly_name") == "HomeTown"
async def test_forecast_service(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
) -> None:
"""Test multiple forecast."""
with patch(
"pyipma.location.Location.get",
return_value=MockLocation(),
):
entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": "weather.hometown",
"type": "daily",
},
blocking=True,
return_response=True,
)
assert response == snapshot
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": "weather.hometown",
"type": "hourly",
},
blocking=True,
return_response=True,
)
assert response == snapshot
@pytest.mark.parametrize("forecast_type", ["daily", "hourly"])
async def test_forecast_subscription(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
forecast_type: str,
) -> None:
"""Test multiple forecast."""
client = await hass_ws_client(hass)
with patch(
"pyipma.location.Location.get",
return_value=MockLocation(),
):
entry = MockConfigEntry(domain="ipma", data=TEST_CONFIG)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await client.send_json_auto_id(
{
"type": "weather/subscribe_forecast",
"forecast_type": forecast_type,
"entity_id": "weather.hometown",
}
)
msg = await client.receive_json()
assert msg["success"]
assert msg["result"] is None
subscription_id = msg["id"]
msg = await client.receive_json()
assert msg["id"] == subscription_id
assert msg["type"] == "event"
forecast1 = msg["event"]["forecast"]
assert forecast1 == snapshot
freezer.tick(MIN_TIME_BETWEEN_UPDATES + datetime.timedelta(seconds=1))
await hass.async_block_till_done()
msg = await client.receive_json()
assert msg["id"] == subscription_id
assert msg["type"] == "event"
forecast2 = msg["event"]["forecast"]
assert forecast2 == snapshot