Add forecasts to MetOffice integration (#50876)

* MetOfficeData now retrieves both 3-hourly and daily data (full forecast data, as well as "now" snapshot) on each update

* Bump datapoint API up to latest version

* Create 2 sets of sensors - one of each set for 3-hourly and for daily data (same ones initially enabled, for now)

* Create two entities (one each for 3-hourly and daily data) and also add in the forecast data for each dataset

* Testing changes to accommodate now having two sets of everything for 3-hourly and daily update data

* Removed unused import (reported by flake8)

* As per conversation with @MatthewFlamm leave the 3-hourly entity's unique_id unchanged (although the display name is changed)

* Make some improvements based on reviews

Make some improvements and fix up the formatting/linting failures.

* Make some improvements based on reviews

Make some improvements and fix up the formatting/linting failures.

* Added more test coverage

* import asyncio

* Try to fix test

* Rewrote everything using CoordinatorEntity

* Fixed config flow

* Fixed lint errors

Co-authored-by: MrHarcombe <ian.harcombe@gmail.com>
Co-authored-by: Henco Appel <hencoappel+github@gmail.com>
This commit is contained in:
avee87 2021-06-27 20:04:42 +01:00 committed by GitHub
parent 23339cff95
commit 2d1744c573
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 659 additions and 236 deletions

View File

@ -1,7 +1,10 @@
"""The Met Office integration."""
import asyncio
import logging
import datapoint
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
@ -11,11 +14,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
DEFAULT_SCAN_INTERVAL,
DOMAIN,
METOFFICE_COORDINATOR,
METOFFICE_DATA,
METOFFICE_COORDINATES,
METOFFICE_DAILY_COORDINATOR,
METOFFICE_HOURLY_COORDINATOR,
METOFFICE_NAME,
MODE_3HOURLY,
MODE_DAILY,
)
from .data import MetOfficeData
from .helpers import fetch_data, fetch_site
_LOGGER = logging.getLogger(__name__)
@ -30,30 +37,53 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
api_key = entry.data[CONF_API_KEY]
site_name = entry.data[CONF_NAME]
metoffice_data = MetOfficeData(hass, api_key, latitude, longitude)
await metoffice_data.async_update_site()
if metoffice_data.site_name is None:
connection = datapoint.connection(api_key=api_key)
site = await hass.async_add_executor_job(
fetch_site, connection, latitude, longitude
)
if site is None:
raise ConfigEntryNotReady()
metoffice_coordinator = DataUpdateCoordinator(
async def async_update_3hourly() -> MetOfficeData:
return await hass.async_add_executor_job(
fetch_data, connection, site, MODE_3HOURLY
)
async def async_update_daily() -> MetOfficeData:
return await hass.async_add_executor_job(
fetch_data, connection, site, MODE_DAILY
)
metoffice_hourly_coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"MetOffice Coordinator for {site_name}",
update_method=metoffice_data.async_update,
name=f"MetOffice Hourly Coordinator for {site_name}",
update_method=async_update_3hourly,
update_interval=DEFAULT_SCAN_INTERVAL,
)
metoffice_daily_coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"MetOffice Daily Coordinator for {site_name}",
update_method=async_update_daily,
update_interval=DEFAULT_SCAN_INTERVAL,
)
metoffice_hass_data = hass.data.setdefault(DOMAIN, {})
metoffice_hass_data[entry.entry_id] = {
METOFFICE_DATA: metoffice_data,
METOFFICE_COORDINATOR: metoffice_coordinator,
METOFFICE_HOURLY_COORDINATOR: metoffice_hourly_coordinator,
METOFFICE_DAILY_COORDINATOR: metoffice_daily_coordinator,
METOFFICE_NAME: site_name,
METOFFICE_COORDINATES: f"{latitude}_{longitude}",
}
# Fetch initial data so we have data when entities subscribe
await metoffice_coordinator.async_refresh()
if metoffice_data.now is None:
raise ConfigEntryNotReady()
await asyncio.gather(
metoffice_hourly_coordinator.async_config_entry_first_refresh(),
metoffice_daily_coordinator.async_config_entry_first_refresh(),
)
hass.config_entries.async_setup_platforms(entry, PLATFORMS)

View File

@ -1,14 +1,15 @@
"""Config flow for Met Office integration."""
import logging
import datapoint
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.components.metoffice.helpers import fetch_site
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .data import MetOfficeData
_LOGGER = logging.getLogger(__name__)
@ -22,12 +23,16 @@ async def validate_input(hass: core.HomeAssistant, data):
longitude = data[CONF_LONGITUDE]
api_key = data[CONF_API_KEY]
metoffice_data = MetOfficeData(hass, api_key, latitude, longitude)
await metoffice_data.async_update_site()
if metoffice_data.site_name is None:
connection = datapoint.connection(api_key=api_key)
site = await hass.async_add_executor_job(
fetch_site, connection, latitude, longitude
)
if site is None:
raise CannotConnect()
return {"site_name": metoffice_data.site_name}
return {"site_name": site.name}
class MetOfficeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):

View File

@ -25,12 +25,16 @@ ATTRIBUTION = "Data provided by the Met Office"
DEFAULT_SCAN_INTERVAL = timedelta(minutes=15)
METOFFICE_DATA = "metoffice_data"
METOFFICE_COORDINATOR = "metoffice_coordinator"
METOFFICE_COORDINATES = "metoffice_coordinates"
METOFFICE_HOURLY_COORDINATOR = "metoffice_hourly_coordinator"
METOFFICE_DAILY_COORDINATOR = "metoffice_daily_coordinator"
METOFFICE_MONITORED_CONDITIONS = "metoffice_monitored_conditions"
METOFFICE_NAME = "metoffice_name"
MODE_3HOURLY = "3hourly"
MODE_3HOURLY_LABEL = "3-Hourly"
MODE_DAILY = "daily"
MODE_DAILY_LABEL = "Daily"
CONDITION_CLASSES = {
ATTR_CONDITION_CLOUDY: ["7", "8"],

View File

@ -1,78 +1,11 @@
"""Common Met Office Data class used by both sensor and entity."""
import logging
import datapoint
from .const import MODE_3HOURLY
_LOGGER = logging.getLogger(__name__)
class MetOfficeData:
"""Get current and forecast data from Datapoint.
"""Data structure for MetOffice weather and forecast."""
Please note that the 'datapoint' library is not asyncio-friendly, so some
calls have had to be wrapped with the standard hassio helper
async_add_executor_job.
"""
def __init__(self, hass, api_key, latitude, longitude):
def __init__(self, now, forecast, site):
"""Initialize the data object."""
self._hass = hass
self._datapoint = datapoint.connection(api_key=api_key)
self._site = None
# Public attributes
self.latitude = latitude
self.longitude = longitude
# Holds the current data from the Met Office
self.site_id = None
self.site_name = None
self.now = None
async def async_update_site(self):
"""Async wrapper for getting the DataPoint site."""
return await self._hass.async_add_executor_job(self._update_site)
def _update_site(self):
"""Return the nearest DataPoint Site to the held latitude/longitude."""
try:
new_site = self._datapoint.get_nearest_forecast_site(
latitude=self.latitude, longitude=self.longitude
)
if self._site is None or self._site.id != new_site.id:
self._site = new_site
self.now = None
self.site_id = self._site.id
self.site_name = self._site.name
except datapoint.exceptions.APIException as err:
_LOGGER.error("Received error from Met Office Datapoint: %s", err)
self._site = None
self.site_id = None
self.site_name = None
self.now = None
return self._site
async def async_update(self):
"""Async wrapper for update method."""
return await self._hass.async_add_executor_job(self._update)
def _update(self):
"""Get the latest data from DataPoint."""
if self._site is None:
_LOGGER.error("No Met Office forecast site held, check logs for problems")
return
try:
forecast = self._datapoint.get_forecast_for_site(
self._site.id, MODE_3HOURLY
)
self.now = forecast.now()
except (ValueError, datapoint.exceptions.APIException) as err:
_LOGGER.error("Check Met Office connection: %s", err.args)
self.now = None
self.now = now
self.forecast = forecast
self.site = site

View File

@ -0,0 +1,44 @@
"""Helpers used for Met Office integration."""
import logging
import datapoint
from homeassistant.helpers.update_coordinator import UpdateFailed
from homeassistant.util import utcnow
from .data import MetOfficeData
_LOGGER = logging.getLogger(__name__)
def fetch_site(connection: datapoint.Manager, latitude, longitude):
"""Fetch site information from Datapoint API."""
try:
return connection.get_nearest_forecast_site(
latitude=latitude, longitude=longitude
)
except datapoint.exceptions.APIException as err:
_LOGGER.error("Received error from Met Office Datapoint: %s", err)
return None
def fetch_data(connection: datapoint.Manager, site, mode) -> MetOfficeData:
"""Fetch weather and forecast from Datapoint API."""
try:
forecast = connection.get_forecast_for_site(site.id, mode)
except (ValueError, datapoint.exceptions.APIException) as err:
_LOGGER.error("Check Met Office connection: %s", err.args)
raise UpdateFailed from err
else:
time_now = utcnow()
return MetOfficeData(
forecast.now(),
[
timestep
for day in forecast.days
for timestep in day.timesteps
if timestep.date > time_now
],
site,
)

View File

@ -2,7 +2,7 @@
"domain": "metoffice",
"name": "Met Office",
"documentation": "https://www.home-assistant.io/integrations/metoffice",
"requirements": ["datapoint==0.9.5"],
"requirements": ["datapoint==0.9.8"],
"codeowners": ["@MrHarcombe"],
"config_flow": true,
"iot_class": "cloud_polling"

View File

@ -10,16 +10,21 @@ from homeassistant.const import (
TEMP_CELSIUS,
UV_INDEX,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTRIBUTION,
CONDITION_CLASSES,
DOMAIN,
METOFFICE_COORDINATOR,
METOFFICE_DATA,
METOFFICE_COORDINATES,
METOFFICE_DAILY_COORDINATOR,
METOFFICE_HOURLY_COORDINATOR,
METOFFICE_NAME,
MODE_3HOURLY_LABEL,
MODE_DAILY,
MODE_DAILY_LABEL,
VISIBILITY_CLASSES,
VISIBILITY_DISTANCE_CLASSES,
)
@ -85,28 +90,40 @@ async def async_setup_entry(
async_add_entities(
[
MetOfficeCurrentSensor(entry.data, hass_data, sensor_type)
MetOfficeCurrentSensor(
hass_data[METOFFICE_HOURLY_COORDINATOR], hass_data, True, sensor_type
)
for sensor_type in SENSOR_TYPES
]
+ [
MetOfficeCurrentSensor(
hass_data[METOFFICE_DAILY_COORDINATOR], hass_data, False, sensor_type
)
for sensor_type in SENSOR_TYPES
],
False,
)
class MetOfficeCurrentSensor(SensorEntity):
class MetOfficeCurrentSensor(CoordinatorEntity, SensorEntity):
"""Implementation of a Met Office current weather condition sensor."""
def __init__(self, entry_data, hass_data, sensor_type):
def __init__(self, coordinator, hass_data, use_3hourly, sensor_type):
"""Initialize the sensor."""
self._data = hass_data[METOFFICE_DATA]
self._coordinator = hass_data[METOFFICE_COORDINATOR]
super().__init__(coordinator)
self._type = sensor_type
self._name = f"{hass_data[METOFFICE_NAME]} {SENSOR_TYPES[self._type][0]}"
self._unique_id = f"{SENSOR_TYPES[self._type][0]}_{self._data.latitude}_{self._data.longitude}"
mode_label = MODE_3HOURLY_LABEL if use_3hourly else MODE_DAILY_LABEL
self._name = (
f"{hass_data[METOFFICE_NAME]} {SENSOR_TYPES[self._type][0]} {mode_label}"
)
self._unique_id = (
f"{SENSOR_TYPES[self._type][0]}_{hass_data[METOFFICE_COORDINATES]}"
)
if not use_3hourly:
self._unique_id = f"{self._unique_id}_{MODE_DAILY}"
self.metoffice_site_id = None
self.metoffice_site_name = None
self.metoffice_now = None
self.use_3hourly = use_3hourly
@property
def name(self):
@ -124,22 +141,26 @@ class MetOfficeCurrentSensor(SensorEntity):
value = None
if self._type == "visibility_distance" and hasattr(
self.metoffice_now, "visibility"
self.coordinator.data.now, "visibility"
):
value = VISIBILITY_DISTANCE_CLASSES.get(self.metoffice_now.visibility.value)
value = VISIBILITY_DISTANCE_CLASSES.get(
self.coordinator.data.now.visibility.value
)
if self._type == "visibility" and hasattr(self.metoffice_now, "visibility"):
value = VISIBILITY_CLASSES.get(self.metoffice_now.visibility.value)
if self._type == "visibility" and hasattr(
self.coordinator.data.now, "visibility"
):
value = VISIBILITY_CLASSES.get(self.coordinator.data.now.visibility.value)
elif self._type == "weather" and hasattr(self.metoffice_now, self._type):
elif self._type == "weather" and hasattr(self.coordinator.data.now, self._type):
value = [
k
for k, v in CONDITION_CLASSES.items()
if self.metoffice_now.weather.value in v
if self.coordinator.data.now.weather.value in v
][0]
elif hasattr(self.metoffice_now, self._type):
value = getattr(self.metoffice_now, self._type)
elif hasattr(self.coordinator.data.now, self._type):
value = getattr(self.coordinator.data.now, self._type)
if not isinstance(value, int):
value = value.value
@ -175,44 +196,13 @@ class MetOfficeCurrentSensor(SensorEntity):
"""Return the state attributes of the device."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
ATTR_LAST_UPDATE: self.metoffice_now.date if self.metoffice_now else None,
ATTR_LAST_UPDATE: self.coordinator.data.now.date,
ATTR_SENSOR_ID: self._type,
ATTR_SITE_ID: self.metoffice_site_id if self.metoffice_site_id else None,
ATTR_SITE_NAME: self.metoffice_site_name
if self.metoffice_site_name
else None,
ATTR_SITE_ID: self.coordinator.data.site.id,
ATTR_SITE_NAME: self.coordinator.data.site.name,
}
async def async_added_to_hass(self) -> None:
"""Set up a listener and load data."""
self.async_on_remove(
self._coordinator.async_add_listener(self._update_callback)
)
self._update_callback()
async def async_update(self):
"""Schedule a custom update via the common entity update service."""
await self._coordinator.async_request_refresh()
@callback
def _update_callback(self) -> None:
"""Load data from integration."""
self.metoffice_site_id = self._data.site_id
self.metoffice_site_name = self._data.site_name
self.metoffice_now = self._data.now
self.async_write_ha_state()
@property
def should_poll(self) -> bool:
"""Entities do not individually poll."""
return False
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if the entity should be enabled when first added to the entity registry."""
return SENSOR_TYPES[self._type][4]
@property
def available(self):
"""Return if state is available."""
return self.metoffice_site_id is not None and self.metoffice_now is not None
return SENSOR_TYPES[self._type][4] and self.use_3hourly

View File

@ -1,17 +1,30 @@
"""Support for UK Met Office weather service."""
from homeassistant.components.weather import WeatherEntity
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
ATTR_FORECAST_PRECIPITATION,
ATTR_FORECAST_TEMP,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
ATTR_FORECAST_WIND_SPEED,
WeatherEntity,
)
from homeassistant.const import LENGTH_KILOMETERS, TEMP_CELSIUS
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTRIBUTION,
CONDITION_CLASSES,
DEFAULT_NAME,
DOMAIN,
METOFFICE_COORDINATOR,
METOFFICE_DATA,
METOFFICE_COORDINATES,
METOFFICE_DAILY_COORDINATOR,
METOFFICE_HOURLY_COORDINATOR,
METOFFICE_NAME,
MODE_3HOURLY_LABEL,
MODE_DAILY,
MODE_DAILY_LABEL,
VISIBILITY_CLASSES,
VISIBILITY_DISTANCE_CLASSES,
)
@ -25,27 +38,48 @@ async def async_setup_entry(
async_add_entities(
[
MetOfficeWeather(
entry.data,
hass_data,
)
MetOfficeWeather(hass_data[METOFFICE_HOURLY_COORDINATOR], hass_data, True),
MetOfficeWeather(hass_data[METOFFICE_DAILY_COORDINATOR], hass_data, False),
],
False,
)
class MetOfficeWeather(WeatherEntity):
def _build_forecast_data(timestep):
data = {}
data[ATTR_FORECAST_TIME] = timestep.date
if timestep.weather:
data[ATTR_FORECAST_CONDITION] = _get_weather_condition(timestep.weather.value)
if timestep.precipitation:
data[ATTR_FORECAST_PRECIPITATION] = timestep.precipitation.value
if timestep.temperature:
data[ATTR_FORECAST_TEMP] = timestep.temperature.value
if timestep.wind_direction:
data[ATTR_FORECAST_WIND_BEARING] = timestep.wind_direction.value
if timestep.wind_speed:
data[ATTR_FORECAST_WIND_SPEED] = timestep.wind_speed.value
return data
def _get_weather_condition(metoffice_code):
for hass_name, metoffice_codes in CONDITION_CLASSES.items():
if metoffice_code in metoffice_codes:
return hass_name
return None
class MetOfficeWeather(CoordinatorEntity, WeatherEntity):
"""Implementation of a Met Office weather condition."""
def __init__(self, entry_data, hass_data):
def __init__(self, coordinator, hass_data, use_3hourly):
"""Initialise the platform with a data instance."""
self._data = hass_data[METOFFICE_DATA]
self._coordinator = hass_data[METOFFICE_COORDINATOR]
super().__init__(coordinator)
self._name = f"{DEFAULT_NAME} {hass_data[METOFFICE_NAME]}"
self._unique_id = f"{self._data.latitude}_{self._data.longitude}"
self.metoffice_now = None
mode_label = MODE_3HOURLY_LABEL if use_3hourly else MODE_DAILY_LABEL
self._name = f"{DEFAULT_NAME} {hass_data[METOFFICE_NAME]} {mode_label}"
self._unique_id = hass_data[METOFFICE_COORDINATES]
if not use_3hourly:
self._unique_id = f"{self._unique_id}_{MODE_DAILY}"
@property
def name(self):
@ -60,24 +94,16 @@ class MetOfficeWeather(WeatherEntity):
@property
def condition(self):
"""Return the current condition."""
return (
[
k
for k, v in CONDITION_CLASSES.items()
if self.metoffice_now.weather.value in v
][0]
if self.metoffice_now
else None
)
if self.coordinator.data.now:
return _get_weather_condition(self.coordinator.data.now.weather.value)
return None
@property
def temperature(self):
"""Return the platform temperature."""
return (
self.metoffice_now.temperature.value
if self.metoffice_now and self.metoffice_now.temperature
else None
)
if self.coordinator.data.now.temperature:
return self.coordinator.data.now.temperature.value
return None
@property
def temperature_unit(self):
@ -88,8 +114,13 @@ class MetOfficeWeather(WeatherEntity):
def visibility(self):
"""Return the platform visibility."""
_visibility = None
if hasattr(self.metoffice_now, "visibility"):
_visibility = f"{VISIBILITY_CLASSES.get(self.metoffice_now.visibility.value)} - {VISIBILITY_DISTANCE_CLASSES.get(self.metoffice_now.visibility.value)}"
weather_now = self.coordinator.data.now
if hasattr(weather_now, "visibility"):
visibility_class = VISIBILITY_CLASSES.get(weather_now.visibility.value)
visibility_distance = VISIBILITY_DISTANCE_CLASSES.get(
weather_now.visibility.value
)
_visibility = f"{visibility_class} - {visibility_distance}"
return _visibility
@property
@ -100,63 +131,46 @@ class MetOfficeWeather(WeatherEntity):
@property
def pressure(self):
"""Return the mean sea-level pressure."""
return (
self.metoffice_now.pressure.value
if self.metoffice_now and self.metoffice_now.pressure
else None
)
weather_now = self.coordinator.data.now
if weather_now and weather_now.pressure:
return weather_now.pressure.value
return None
@property
def humidity(self):
"""Return the relative humidity."""
return (
self.metoffice_now.humidity.value
if self.metoffice_now and self.metoffice_now.humidity
else None
)
weather_now = self.coordinator.data.now
if weather_now and weather_now.humidity:
return weather_now.humidity.value
return None
@property
def wind_speed(self):
"""Return the wind speed."""
return (
self.metoffice_now.wind_speed.value
if self.metoffice_now and self.metoffice_now.wind_speed
else None
)
weather_now = self.coordinator.data.now
if weather_now and weather_now.wind_speed:
return weather_now.wind_speed.value
return None
@property
def wind_bearing(self):
"""Return the wind bearing."""
return (
self.metoffice_now.wind_direction.value
if self.metoffice_now and self.metoffice_now.wind_direction
else None
)
weather_now = self.coordinator.data.now
if weather_now and weather_now.wind_direction:
return weather_now.wind_direction.value
return None
@property
def forecast(self):
"""Return the forecast array."""
if self.coordinator.data.forecast is None:
return None
return [
_build_forecast_data(timestep)
for timestep in self.coordinator.data.forecast
]
@property
def attribution(self):
"""Return the attribution."""
return ATTRIBUTION
async def async_added_to_hass(self) -> None:
"""Set up a listener and load data."""
self.async_on_remove(
self._coordinator.async_add_listener(self._update_callback)
)
self._update_callback()
@callback
def _update_callback(self) -> None:
"""Load data from integration."""
self.metoffice_now = self._data.now
self.async_write_ha_state()
@property
def should_poll(self) -> bool:
"""Entities do not individually poll."""
return False
@property
def available(self):
"""Return if state is available."""
return self.metoffice_now is not None

View File

@ -473,7 +473,7 @@ coronavirus==1.1.1
datadog==0.15.0
# homeassistant.components.metoffice
datapoint==0.9.5
datapoint==0.9.8
# homeassistant.components.debugpy
debugpy==1.3.0

View File

@ -267,7 +267,7 @@ coronavirus==1.1.1
datadog==0.15.0
# homeassistant.components.metoffice
datapoint==0.9.5
datapoint==0.9.8
# homeassistant.components.debugpy
debugpy==1.3.0

View File

@ -68,6 +68,10 @@ async def test_form_already_configured(hass, requests_mock):
"/public/data/val/wxfcs/all/json/354107?res=3hourly",
text="",
)
requests_mock.get(
"/public/data/val/wxfcs/all/json/354107?res=daily",
text="",
)
MockConfigEntry(
domain=DOMAIN,

View File

@ -29,12 +29,17 @@ async def test_one_sensor_site_running(hass, requests_mock, legacy_patchable_tim
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,
)
entry = MockConfigEntry(
domain=DOMAIN,
@ -72,15 +77,23 @@ async def test_two_sensor_sites_running(hass, requests_mock, legacy_patchable_ti
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
)
requests_mock.get(
"/public/data/val/wxfcs/all/json/322380?res=daily", text=kingslynn_daily
)
entry = MockConfigEntry(
domain=DOMAIN,

View File

@ -9,6 +9,7 @@ from homeassistant.util import utcnow
from . import NewDateTime
from .const import (
DATETIME_FORMAT,
METOFFICE_CONFIG_KINGSLYNN,
METOFFICE_CONFIG_WAVERTREE,
WAVERTREE_SENSOR_RESULTS,
@ -26,6 +27,7 @@ async def test_site_cannot_connect(hass, requests_mock, legacy_patchable_time):
requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text="")
requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="")
requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=daily", text="")
entry = MockConfigEntry(
domain=DOMAIN,
@ -35,9 +37,10 @@ async def test_site_cannot_connect(hass, requests_mock, legacy_patchable_time):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get("weather.met_office_wavertree") is None
assert hass.states.get("weather.met_office_wavertree_3hourly") is None
assert hass.states.get("weather.met_office_wavertree_daily") is None
for sensor_id in WAVERTREE_SENSOR_RESULTS:
sensor_name, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id]
sensor_name, _ = WAVERTREE_SENSOR_RESULTS[sensor_id]
sensor = hass.states.get(f"sensor.wavertree_{sensor_name}")
assert sensor is None
@ -53,11 +56,15 @@ async def test_site_cannot_update(hass, requests_mock, legacy_patchable_time):
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
)
entry = MockConfigEntry(
domain=DOMAIN,
@ -67,16 +74,23 @@ async def test_site_cannot_update(hass, requests_mock, legacy_patchable_time):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity = hass.states.get("weather.met_office_wavertree")
entity = hass.states.get("weather.met_office_wavertree_3_hourly")
assert entity
entity = hass.states.get("weather.met_office_wavertree_daily")
assert entity
requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="")
requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=daily", text="")
future_time = utcnow() + timedelta(minutes=20)
async_fire_time_changed(hass, future_time)
await hass.async_block_till_done()
entity = hass.states.get("weather.met_office_wavertree")
entity = hass.states.get("weather.met_office_wavertree_3_hourly")
assert entity.state == STATE_UNAVAILABLE
entity = hass.states.get("weather.met_office_wavertree_daily")
assert entity.state == STATE_UNAVAILABLE
@ -91,12 +105,17 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti
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,
)
entry = MockConfigEntry(
domain=DOMAIN,
@ -106,8 +125,8 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Wavertree weather platform expected results
entity = hass.states.get("weather.met_office_wavertree")
# Wavertree 3-hourly weather platform expected results
entity = hass.states.get("weather.met_office_wavertree_3_hourly")
assert entity
assert entity.state == "sunny"
@ -117,6 +136,41 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti
assert entity.attributes.get("visibility") == "Good - 10-20"
assert entity.attributes.get("humidity") == 50
# Forecasts added - just pick out 1 entry to check
assert len(entity.attributes.get("forecast")) == 35
assert (
entity.attributes.get("forecast")[26]["datetime"].strftime(DATETIME_FORMAT)
== "2020-04-28 21:00:00+0000"
)
assert entity.attributes.get("forecast")[26]["condition"] == "cloudy"
assert entity.attributes.get("forecast")[26]["temperature"] == 10
assert entity.attributes.get("forecast")[26]["wind_speed"] == 4
assert entity.attributes.get("forecast")[26]["wind_bearing"] == "NNE"
# Wavertree daily weather platform expected results
entity = hass.states.get("weather.met_office_wavertree_daily")
assert entity
assert entity.state == "sunny"
assert entity.attributes.get("temperature") == 19
assert entity.attributes.get("wind_speed") == 9
assert entity.attributes.get("wind_bearing") == "SSE"
assert entity.attributes.get("visibility") == "Good - 10-20"
assert entity.attributes.get("humidity") == 50
# Also has Forecasts added - again, just pick out 1 entry to check
assert len(entity.attributes.get("forecast")) == 8
assert (
entity.attributes.get("forecast")[7]["datetime"].strftime(DATETIME_FORMAT)
== "2020-04-29 12:00:00+0000"
)
assert entity.attributes.get("forecast")[7]["condition"] == "rainy"
assert entity.attributes.get("forecast")[7]["temperature"] == 13
assert entity.attributes.get("forecast")[7]["wind_speed"] == 13
assert entity.attributes.get("forecast")[7]["wind_bearing"] == "SE"
@patch(
"datapoint.Forecast.datetime.datetime",
@ -129,15 +183,23 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t
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
)
requests_mock.get(
"/public/data/val/wxfcs/all/json/322380?res=daily", text=kingslynn_daily
)
entry = MockConfigEntry(
domain=DOMAIN,
@ -153,8 +215,8 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t
await hass.config_entries.async_setup(entry2.entry_id)
await hass.async_block_till_done()
# Wavertree weather platform expected results
entity = hass.states.get("weather.met_office_wavertree")
# Wavertree 3-hourly weather platform expected results
entity = hass.states.get("weather.met_office_wavertree_3_hourly")
assert entity
assert entity.state == "sunny"
@ -164,8 +226,43 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t
assert entity.attributes.get("visibility") == "Good - 10-20"
assert entity.attributes.get("humidity") == 50
# King's Lynn weather platform expected results
entity = hass.states.get("weather.met_office_king_s_lynn")
# Forecasts added - just pick out 1 entry to check
assert len(entity.attributes.get("forecast")) == 35
assert (
entity.attributes.get("forecast")[18]["datetime"].strftime(DATETIME_FORMAT)
== "2020-04-27 21:00:00+0000"
)
assert entity.attributes.get("forecast")[18]["condition"] == "sunny"
assert entity.attributes.get("forecast")[18]["temperature"] == 9
assert entity.attributes.get("forecast")[18]["wind_speed"] == 4
assert entity.attributes.get("forecast")[18]["wind_bearing"] == "NW"
# Wavertree daily weather platform expected results
entity = hass.states.get("weather.met_office_wavertree_daily")
assert entity
assert entity.state == "sunny"
assert entity.attributes.get("temperature") == 19
assert entity.attributes.get("wind_speed") == 9
assert entity.attributes.get("wind_bearing") == "SSE"
assert entity.attributes.get("visibility") == "Good - 10-20"
assert entity.attributes.get("humidity") == 50
# Also has Forecasts added - again, just pick out 1 entry to check
assert len(entity.attributes.get("forecast")) == 8
assert (
entity.attributes.get("forecast")[7]["datetime"].strftime(DATETIME_FORMAT)
== "2020-04-29 12:00:00+0000"
)
assert entity.attributes.get("forecast")[7]["condition"] == "rainy"
assert entity.attributes.get("forecast")[7]["temperature"] == 13
assert entity.attributes.get("forecast")[7]["wind_speed"] == 13
assert entity.attributes.get("forecast")[7]["wind_bearing"] == "SE"
# King's Lynn 3-hourly weather platform expected results
entity = hass.states.get("weather.met_office_king_s_lynn_3_hourly")
assert entity
assert entity.state == "sunny"
@ -174,3 +271,38 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t
assert entity.attributes.get("wind_bearing") == "E"
assert entity.attributes.get("visibility") == "Very Good - 20-40"
assert entity.attributes.get("humidity") == 60
# Also has Forecast added - just pick out 1 entry to check
assert len(entity.attributes.get("forecast")) == 35
assert (
entity.attributes.get("forecast")[18]["datetime"].strftime(DATETIME_FORMAT)
== "2020-04-27 21:00:00+0000"
)
assert entity.attributes.get("forecast")[18]["condition"] == "cloudy"
assert entity.attributes.get("forecast")[18]["temperature"] == 10
assert entity.attributes.get("forecast")[18]["wind_speed"] == 7
assert entity.attributes.get("forecast")[18]["wind_bearing"] == "SE"
# King's Lynn daily weather platform expected results
entity = hass.states.get("weather.met_office_king_s_lynn_daily")
assert entity
assert entity.state == "cloudy"
assert entity.attributes.get("temperature") == 9
assert entity.attributes.get("wind_speed") == 4
assert entity.attributes.get("wind_bearing") == "ESE"
assert entity.attributes.get("visibility") == "Very Good - 20-40"
assert entity.attributes.get("humidity") == 75
# All should have Forecast added - again, just picking out 1 entry to check
assert len(entity.attributes.get("forecast")) == 8
assert (
entity.attributes.get("forecast")[5]["datetime"].strftime(DATETIME_FORMAT)
== "2020-04-28 12:00:00+0000"
)
assert entity.attributes.get("forecast")[5]["condition"] == "cloudy"
assert entity.attributes.get("forecast")[5]["temperature"] == 11
assert entity.attributes.get("forecast")[5]["wind_speed"] == 7
assert entity.attributes.get("forecast")[5]["wind_bearing"] == "ESE"

View File

@ -218,6 +218,7 @@
"U": "0",
"$": "180"
},
{
"D": "NW",
"F": "10",
@ -1495,5 +1496,258 @@
}
}
}
},
"kingslynn_daily": {
"SiteRep": {
"Wx": {
"Param": [
{
"name": "FDm",
"units": "C",
"$": "Feels Like Day Maximum Temperature"
},
{
"name": "FNm",
"units": "C",
"$": "Feels Like Night Minimum Temperature"
},
{
"name": "Dm",
"units": "C",
"$": "Day Maximum Temperature"
},
{
"name": "Nm",
"units": "C",
"$": "Night Minimum Temperature"
},
{
"name": "Gn",
"units": "mph",
"$": "Wind Gust Noon"
},
{
"name": "Gm",
"units": "mph",
"$": "Wind Gust Midnight"
},
{
"name": "Hn",
"units": "%",
"$": "Screen Relative Humidity Noon"
},
{
"name": "Hm",
"units": "%",
"$": "Screen Relative Humidity Midnight"
},
{
"name": "V",
"units": "",
"$": "Visibility"
},
{
"name": "D",
"units": "compass",
"$": "Wind Direction"
},
{
"name": "S",
"units": "mph",
"$": "Wind Speed"
},
{
"name": "U",
"units": "",
"$": "Max UV Index"
},
{
"name": "W",
"units": "",
"$": "Weather Type"
},
{
"name": "PPd",
"units": "%",
"$": "Precipitation Probability Day"
},
{
"name": "PPn",
"units": "%",
"$": "Precipitation Probability Night"
}
]
},
"DV": {
"dataDate": "2020-04-25T08:00:00Z",
"type": "Forecast",
"Location": {
"i": "322380",
"lat": "52.7561",
"lon": "0.4019",
"name": "KING'S LYNN",
"country": "ENGLAND",
"continent": "EUROPE",
"elevation": "5.0",
"Period": [
{
"type": "Day",
"value": "2020-04-25Z",
"Rep": [
{
"D": "ESE",
"Gn": "4",
"Hn": "75",
"PPd": "9",
"S": "4",
"V": "VG",
"Dm": "9",
"FDm": "8",
"W": "8",
"U": "3",
"$": "Day"
},
{
"D": "SSE",
"Gm": "16",
"Hm": "84",
"PPn": "0",
"S": "7",
"V": "VG",
"Nm": "7",
"FNm": "5",
"W": "0",
"$": "Night"
}
]
},
{
"type": "Day",
"value": "2020-04-26Z",
"Rep": [
{
"D": "SSW",
"Gn": "13",
"Hn": "69",
"PPd": "0",
"S": "9",
"V": "VG",
"Dm": "13",
"FDm": "11",
"W": "1",
"U": "4",
"$": "Day"
},
{
"D": "SSW",
"Gm": "13",
"Hm": "75",
"PPn": "5",
"S": "7",
"V": "GO",
"Nm": "11",
"FNm": "10",
"W": "7",
"$": "Night"
}
]
},
{
"type": "Day",
"value": "2020-04-27Z",
"Rep": [
{
"D": "NW",
"Gn": "11",
"Hn": "78",
"PPd": "36",
"S": "4",
"V": "VG",
"Dm": "10",
"FDm": "9",
"W": "7",
"U": "3",
"$": "Day"
},
{
"D": "SE",
"Gm": "13",
"Hm": "85",
"PPn": "9",
"S": "7",
"V": "VG",
"Nm": "9",
"FNm": "7",
"W": "7",
"$": "Night"
}
]
},
{
"type": "Day",
"value": "2020-04-28Z",
"Rep": [
{
"D": "ESE",
"Gn": "13",
"Hn": "77",
"PPd": "14",
"S": "7",
"V": "GO",
"Dm": "11",
"FDm": "9",
"W": "7",
"U": "3",
"$": "Day"
},
{
"D": "SSE",
"Gm": "13",
"Hm": "87",
"PPn": "11",
"S": "7",
"V": "GO",
"Nm": "9",
"FNm": "7",
"W": "7",
"$": "Night"
}
]
},
{
"type": "Day",
"value": "2020-04-29Z",
"Rep": [
{
"D": "SSE",
"Gn": "20",
"Hn": "75",
"PPd": "8",
"S": "11",
"V": "VG",
"Dm": "12",
"FDm": "10",
"W": "7",
"U": "3",
"$": "Day"
},
{
"D": "SSE",
"Gm": "20",
"Hm": "86",
"PPn": "20",
"S": "11",
"V": "VG",
"Nm": "9",
"FNm": "7",
"W": "7",
"$": "Night"
}
]
}
]
}
}
}
}
}