Modernize metoffice weather (#99050)

* Modernize metoffice weather

* Exclude sensors from additional test
This commit is contained in:
Erik Montnemery 2023-10-10 16:49:36 +02:00 committed by GitHub
parent 31bd500222
commit ecdb0bb46a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 2308 additions and 63 deletions

View File

@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from .const import (
DEFAULT_SCAN_INTERVAL,
@ -105,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
fetch_data, connection, site, MODE_DAILY
)
metoffice_hourly_coordinator = DataUpdateCoordinator(
metoffice_hourly_coordinator = TimestampDataUpdateCoordinator(
hass,
_LOGGER,
name=f"MetOffice Hourly Coordinator for {site_name}",
@ -113,7 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
update_interval=DEFAULT_SCAN_INTERVAL,
)
metoffice_daily_coordinator = DataUpdateCoordinator(
metoffice_daily_coordinator = TimestampDataUpdateCoordinator(
hass,
_LOGGER,
name=f"MetOffice Daily Coordinator for {site_name}",

View File

@ -1,7 +1,7 @@
"""Support for UK Met Office weather service."""
from __future__ import annotations
from typing import Any
from typing import Any, cast
from datapoint.Timestep import Timestep
@ -11,17 +11,17 @@ from homeassistant.components.weather import (
ATTR_FORECAST_NATIVE_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_WIND_BEARING,
DOMAIN as WEATHER_DOMAIN,
CoordinatorWeatherEntity,
Forecast,
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from . import get_device_info
from .const import (
@ -41,15 +41,34 @@ async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Met Office weather sensor platform."""
entity_registry = er.async_get(hass)
hass_data = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
[
MetOfficeWeather(hass_data[METOFFICE_HOURLY_COORDINATOR], hass_data, True),
MetOfficeWeather(hass_data[METOFFICE_DAILY_COORDINATOR], hass_data, False),
],
False,
)
entities = [
MetOfficeWeather(
hass_data[METOFFICE_DAILY_COORDINATOR],
hass_data[METOFFICE_HOURLY_COORDINATOR],
hass_data,
False,
)
]
# Add hourly entity to legacy config entries
if entity_registry.async_get_entity_id(
WEATHER_DOMAIN,
DOMAIN,
_calculate_unique_id(hass_data[METOFFICE_COORDINATES], True),
):
entities.append(
MetOfficeWeather(
hass_data[METOFFICE_DAILY_COORDINATOR],
hass_data[METOFFICE_HOURLY_COORDINATOR],
hass_data,
True,
)
)
async_add_entities(entities, False)
def _build_forecast_data(timestep: Timestep) -> Forecast:
@ -67,8 +86,20 @@ def _build_forecast_data(timestep: Timestep) -> Forecast:
return data
def _calculate_unique_id(coordinates: str, use_3hourly: bool) -> str:
"""Calculate unique ID."""
if use_3hourly:
return coordinates
return f"{coordinates}_{MODE_DAILY}"
class MetOfficeWeather(
CoordinatorEntity[DataUpdateCoordinator[MetOfficeData]], WeatherEntity
CoordinatorWeatherEntity[
TimestampDataUpdateCoordinator[MetOfficeData],
TimestampDataUpdateCoordinator[MetOfficeData],
TimestampDataUpdateCoordinator[MetOfficeData],
TimestampDataUpdateCoordinator[MetOfficeData], # Can be removed in Python 3.12
]
):
"""Implementation of a Met Office weather condition."""
@ -78,23 +109,36 @@ class MetOfficeWeather(
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_pressure_unit = UnitOfPressure.HPA
_attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR
_attr_supported_features = (
WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_DAILY
)
def __init__(
self,
coordinator: DataUpdateCoordinator[MetOfficeData],
coordinator_daily: TimestampDataUpdateCoordinator[MetOfficeData],
coordinator_hourly: TimestampDataUpdateCoordinator[MetOfficeData],
hass_data: dict[str, Any],
use_3hourly: bool,
) -> None:
"""Initialise the platform with a data instance."""
super().__init__(coordinator)
self._hourly = use_3hourly
if use_3hourly:
observation_coordinator = coordinator_hourly
else:
observation_coordinator = coordinator_daily
super().__init__(
observation_coordinator,
daily_coordinator=coordinator_daily,
hourly_coordinator=coordinator_hourly,
)
self._attr_device_info = get_device_info(
coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME]
)
self._attr_name = "3-Hourly" if use_3hourly else "Daily"
self._attr_unique_id = hass_data[METOFFICE_COORDINATES]
if not use_3hourly:
self._attr_unique_id = f"{self._attr_unique_id}_{MODE_DAILY}"
self._attr_unique_id = _calculate_unique_id(
hass_data[METOFFICE_COORDINATES], use_3hourly
)
@property
def condition(self) -> str | None:
@ -155,3 +199,25 @@ class MetOfficeWeather(
_build_forecast_data(timestep)
for timestep in self.coordinator.data.forecast
]
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
"""Return the twice daily forecast in native units."""
coordinator = cast(
TimestampDataUpdateCoordinator[MetOfficeData],
self.forecast_coordinators["daily"],
)
return [
_build_forecast_data(timestep) for timestep in coordinator.data.forecast
]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Return the hourly forecast in native units."""
coordinator = cast(
TimestampDataUpdateCoordinator[MetOfficeData],
self.forecast_coordinators["hourly"],
)
return [
_build_forecast_data(timestep) for timestep in coordinator.data.forecast
]

File diff suppressed because it is too large Load Diff

View File

@ -2,13 +2,22 @@
import datetime
from datetime import timedelta
import json
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
import requests_mock
from requests_mock.adapter import _Matcher
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.metoffice.const import DOMAIN
from homeassistant.components.metoffice.const import DEFAULT_SCAN_INTERVAL, DOMAIN
from homeassistant.components.weather import (
DOMAIN as WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
)
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import async_get as get_dev_reg
from homeassistant.util import utcnow
@ -21,6 +30,43 @@ from .const import (
)
from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture
from tests.typing import WebSocketGenerator
@pytest.fixture
def no_sensor():
"""Remove sensors."""
with patch(
"homeassistant.components.metoffice.sensor.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
async def wavertree_data(requests_mock: requests_mock.Mocker) -> dict[str, _Matcher]:
"""Mock data for the Wavertree location."""
# all metoffice test data encapsulated in here
mock_json = json.loads(load_fixture("metoffice.json"))
all_sites = json.dumps(mock_json["all_sites"])
wavertree_hourly = json.dumps(mock_json["wavertree_hourly"])
wavertree_daily = json.dumps(mock_json["wavertree_daily"])
sitelist_mock = requests_mock.get(
"/public/data/val/wxfcs/all/json/sitelist/", text=all_sites
)
wavertree_hourly_mock = requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=3hourly",
text=wavertree_hourly,
)
wavertree_daily_mock = requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=daily",
text=wavertree_daily,
)
return {
"sitelist_mock": sitelist_mock,
"wavertree_hourly_mock": wavertree_hourly_mock,
"wavertree_daily_mock": wavertree_daily_mock,
}
@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC))
@ -54,22 +100,17 @@ async def test_site_cannot_connect(
@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC))
async def test_site_cannot_update(
hass: HomeAssistant, requests_mock: requests_mock.Mocker
hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data
) -> None:
"""Test we handle cannot connect error."""
registry = er.async_get(hass)
# all metoffice test data encapsulated in here
mock_json = json.loads(load_fixture("metoffice.json"))
all_sites = json.dumps(mock_json["all_sites"])
wavertree_hourly = json.dumps(mock_json["wavertree_hourly"])
wavertree_daily = json.dumps(mock_json["wavertree_daily"])
requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites)
requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly
)
requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=daily", text=wavertree_daily
# Pre-create the hourly entity
registry.async_get_or_create(
WEATHER_DOMAIN,
DOMAIN,
"53.38374_-2.90929",
suggested_object_id="met_office_wavertree_3_hourly",
)
entry = MockConfigEntry(
@ -102,24 +143,17 @@ async def test_site_cannot_update(
@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC))
async def test_one_weather_site_running(
hass: HomeAssistant, requests_mock: requests_mock.Mocker
hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data
) -> None:
"""Test the Met Office weather platform."""
registry = er.async_get(hass)
# all metoffice test data encapsulated in here
mock_json = json.loads(load_fixture("metoffice.json"))
all_sites = json.dumps(mock_json["all_sites"])
wavertree_hourly = json.dumps(mock_json["wavertree_hourly"])
wavertree_daily = json.dumps(mock_json["wavertree_daily"])
requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites)
requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=3hourly",
text=wavertree_hourly,
)
requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=daily",
text=wavertree_daily,
# Pre-create the hourly entity
registry.async_get_or_create(
WEATHER_DOMAIN,
DOMAIN,
"53.38374_-2.90929",
suggested_object_id="met_office_wavertree_3_hourly",
)
entry = MockConfigEntry(
@ -185,25 +219,30 @@ async def test_one_weather_site_running(
@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC))
async def test_two_weather_sites_running(
hass: HomeAssistant, requests_mock: requests_mock.Mocker
hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data
) -> None:
"""Test we handle two different weather sites both running."""
registry = er.async_get(hass)
# Pre-create the hourly entities
registry.async_get_or_create(
WEATHER_DOMAIN,
DOMAIN,
"53.38374_-2.90929",
suggested_object_id="met_office_wavertree_3_hourly",
)
registry.async_get_or_create(
WEATHER_DOMAIN,
DOMAIN,
"52.75556_0.44231",
suggested_object_id="met_office_king_s_lynn_3_hourly",
)
# all metoffice test data encapsulated in here
mock_json = json.loads(load_fixture("metoffice.json"))
all_sites = json.dumps(mock_json["all_sites"])
wavertree_hourly = json.dumps(mock_json["wavertree_hourly"])
wavertree_daily = json.dumps(mock_json["wavertree_daily"])
kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"])
kingslynn_daily = json.dumps(mock_json["kingslynn_daily"])
requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites)
requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly
)
requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=daily", text=wavertree_daily
)
requests_mock.get(
"/public/data/val/wxfcs/all/json/322380?res=3hourly", text=kingslynn_hourly
)
@ -327,3 +366,214 @@ async def test_two_weather_sites_running(
assert weather.attributes.get("forecast")[2]["temperature"] == 11
assert weather.attributes.get("forecast")[2]["wind_speed"] == 11.27
assert weather.attributes.get("forecast")[2]["wind_bearing"] == "ESE"
@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC))
async def test_new_config_entry(hass: HomeAssistant, no_sensor, wavertree_data) -> None:
"""Test the expected entities are created."""
registry = er.async_get(hass)
entry = MockConfigEntry(
domain=DOMAIN,
data=METOFFICE_CONFIG_WAVERTREE,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1
entry = hass.config_entries.async_entries()[0]
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1
@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC))
async def test_legacy_config_entry(
hass: HomeAssistant, no_sensor, wavertree_data
) -> None:
"""Test the expected entities are created."""
registry = er.async_get(hass)
# Pre-create the hourly entity
registry.async_get_or_create(
WEATHER_DOMAIN,
DOMAIN,
"53.38374_-2.90929",
suggested_object_id="met_office_wavertree_3_hourly",
)
entry = MockConfigEntry(
domain=DOMAIN,
data=METOFFICE_CONFIG_WAVERTREE,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids("weather")) == 2
entry = hass.config_entries.async_entries()[0]
assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2
@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC))
async def test_forecast_service(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
requests_mock: requests_mock.Mocker,
snapshot: SnapshotAssertion,
no_sensor,
wavertree_data: dict[str, _Matcher],
) -> None:
"""Test multiple forecast."""
entry = MockConfigEntry(
domain=DOMAIN,
data=METOFFICE_CONFIG_WAVERTREE,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert wavertree_data["wavertree_daily_mock"].call_count == 1
assert wavertree_data["wavertree_hourly_mock"].call_count == 1
for forecast_type in ("daily", "hourly"):
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": "weather.met_office_wavertree_daily",
"type": forecast_type,
},
blocking=True,
return_response=True,
)
assert response["forecast"] != []
assert response == snapshot
# Calling the services should use cached data
assert wavertree_data["wavertree_daily_mock"].call_count == 1
assert wavertree_data["wavertree_hourly_mock"].call_count == 1
# Trigger data refetch
freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert wavertree_data["wavertree_daily_mock"].call_count == 2
assert wavertree_data["wavertree_hourly_mock"].call_count == 1
for forecast_type in ("daily", "hourly"):
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": "weather.met_office_wavertree_daily",
"type": forecast_type,
},
blocking=True,
return_response=True,
)
assert response["forecast"] != []
assert response == snapshot
# Calling the services should update the hourly forecast
assert wavertree_data["wavertree_daily_mock"].call_count == 2
assert wavertree_data["wavertree_hourly_mock"].call_count == 2
# Update fails
requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="")
freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
response = await hass.services.async_call(
WEATHER_DOMAIN,
SERVICE_GET_FORECAST,
{
"entity_id": "weather.met_office_wavertree_daily",
"type": "hourly",
},
blocking=True,
return_response=True,
)
assert response["forecast"] == []
@pytest.mark.parametrize(
"entity_id",
[
"weather.met_office_wavertree_3_hourly",
"weather.met_office_wavertree_daily",
],
)
@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC))
async def test_forecast_subscription(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
freezer: FrozenDateTimeFactory,
snapshot: SnapshotAssertion,
no_sensor,
wavertree_data: dict[str, _Matcher],
entity_id: str,
) -> None:
"""Test multiple forecast."""
client = await hass_ws_client(hass)
registry = er.async_get(hass)
# Pre-create the hourly entity
registry.async_get_or_create(
WEATHER_DOMAIN,
DOMAIN,
"53.38374_-2.90929",
suggested_object_id="met_office_wavertree_3_hourly",
)
entry = MockConfigEntry(
domain=DOMAIN,
data=METOFFICE_CONFIG_WAVERTREE,
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
for forecast_type in ("daily", "hourly"):
await client.send_json_auto_id(
{
"type": "weather/subscribe_forecast",
"forecast_type": forecast_type,
"entity_id": entity_id,
}
)
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 != []
assert forecast1 == snapshot
freezer.tick(DEFAULT_SCAN_INTERVAL + 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 != []
assert forecast2 == snapshot
await client.send_json_auto_id(
{
"type": "unsubscribe_events",
"subscription": subscription_id,
}
)
msg = await client.receive_json()
assert msg["success"]