diff --git a/CODEOWNERS b/CODEOWNERS index d93ed8cdf31..e63ab433fdd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -244,6 +244,7 @@ homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen homeassistant/components/meteo_france/* @victorcerutti @oncleben31 @Quentame homeassistant/components/meteoalarm/* @rolfberkenbosch +homeassistant/components/metoffice/* @MrHarcombe homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel homeassistant/components/mikrotik/* @engrbm87 homeassistant/components/mill/* @danielhiversen diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 94cc8b636d4..8a68646240a 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -1 +1,86 @@ -"""The metoffice component.""" +"""The Met Office integration.""" + +import asyncio +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import ( + DEFAULT_SCAN_INTERVAL, + DOMAIN, + METOFFICE_COORDINATOR, + METOFFICE_DATA, + METOFFICE_NAME, +) +from .data import MetOfficeData + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["sensor", "weather"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Met Office weather component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up a Met Office entry.""" + + latitude = entry.data[CONF_LATITUDE] + longitude = entry.data[CONF_LONGITUDE] + 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: + raise ConfigEntryNotReady() + + metoffice_coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"MetOffice Coordinator for {site_name}", + update_method=metoffice_data.async_update, + 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_NAME: site_name, + } + + # Fetch initial data so we have data when entities subscribe + await metoffice_coordinator.async_refresh() + if metoffice_data.now is None: + raise ConfigEntryNotReady() + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + return unload_ok diff --git a/homeassistant/components/metoffice/config_flow.py b/homeassistant/components/metoffice/config_flow.py new file mode 100644 index 00000000000..b71c3de67e3 --- /dev/null +++ b/homeassistant/components/metoffice/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for Met Office integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +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 # pylint: disable=unused-import +from .data import MetOfficeData + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate that the user input allows us to connect to DataPoint. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + latitude = data[CONF_LATITUDE] + 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: + raise CannotConnect() + + return {"site_name": metoffice_data.site_name} + + +class MetOfficeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Met Office weather integration.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + await self.async_set_unique_id( + f"{user_input[CONF_LATITUDE]}_{user_input[CONF_LONGITUDE]}" + ) + self._abort_if_unique_id_configured() + + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + user_input[CONF_NAME] = info["site_name"] + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + data_schema = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + }, + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/metoffice/const.py b/homeassistant/components/metoffice/const.py new file mode 100644 index 00000000000..b088672b8a5 --- /dev/null +++ b/homeassistant/components/metoffice/const.py @@ -0,0 +1,51 @@ +"""Constants for Met Office Integration.""" +from datetime import timedelta + +DOMAIN = "metoffice" + +DEFAULT_NAME = "Met Office" +ATTRIBUTION = "Data provided by the Met Office" + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=15) + +METOFFICE_DATA = "metoffice_data" +METOFFICE_COORDINATOR = "metoffice_coordinator" +METOFFICE_MONITORED_CONDITIONS = "metoffice_monitored_conditions" +METOFFICE_NAME = "metoffice_name" + +MODE_3HOURLY = "3hourly" + +CONDITION_CLASSES = { + "cloudy": ["7", "8"], + "fog": ["5", "6"], + "hail": ["19", "20", "21"], + "lightning": ["30"], + "lightning-rainy": ["28", "29"], + "partlycloudy": ["2", "3"], + "pouring": ["13", "14", "15"], + "rainy": ["9", "10", "11", "12"], + "snowy": ["22", "23", "24", "25", "26", "27"], + "snowy-rainy": ["16", "17", "18"], + "sunny": ["0", "1"], + "windy": [], + "windy-variant": [], + "exceptional": [], +} + +VISIBILITY_CLASSES = { + "VP": "Very Poor", + "PO": "Poor", + "MO": "Moderate", + "GO": "Good", + "VG": "Very Good", + "EX": "Excellent", +} + +VISIBILITY_DISTANCE_CLASSES = { + "VP": "<1", + "PO": "1-4", + "MO": "4-10", + "GO": "10-20", + "VG": "20-40", + "EX": ">40", +} diff --git a/homeassistant/components/metoffice/data.py b/homeassistant/components/metoffice/data.py new file mode 100644 index 00000000000..8f718b8d4b8 --- /dev/null +++ b/homeassistant/components/metoffice/data.py @@ -0,0 +1,78 @@ +"""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. + + 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): + """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 diff --git a/homeassistant/components/metoffice/manifest.json b/homeassistant/components/metoffice/manifest.json index 20120d90b18..0c5d4e1d625 100644 --- a/homeassistant/components/metoffice/manifest.json +++ b/homeassistant/components/metoffice/manifest.json @@ -3,5 +3,6 @@ "name": "Met Office", "documentation": "https://www.home-assistant.io/integrations/metoffice", "requirements": ["datapoint==0.9.5"], - "codeowners": [] + "codeowners": ["@MrHarcombe"], + "config_flow": true } diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index b594517ac50..e314423a0a5 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -1,27 +1,31 @@ """Support for UK Met Office weather service.""" -from datetime import timedelta + import logging -import datapoint as dp -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, - CONF_NAME, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, LENGTH_KILOMETERS, SPEED_MILES_PER_HOUR, TEMP_CELSIUS, UNIT_PERCENTAGE, UV_INDEX, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import ( + ATTRIBUTION, + CONDITION_CLASSES, + DOMAIN, + METOFFICE_COORDINATOR, + METOFFICE_DATA, + METOFFICE_NAME, + VISIBILITY_CLASSES, + VISIBILITY_DISTANCE_CLASSES, +) _LOGGER = logging.getLogger(__name__) @@ -30,175 +34,190 @@ ATTR_SENSOR_ID = "sensor_id" ATTR_SITE_ID = "site_id" ATTR_SITE_NAME = "site_name" -ATTRIBUTION = "Data provided by the Met Office" - -CONDITION_CLASSES = { - "cloudy": ["7", "8"], - "fog": ["5", "6"], - "hail": ["19", "20", "21"], - "lightning": ["30"], - "lightning-rainy": ["28", "29"], - "partlycloudy": ["2", "3"], - "pouring": ["13", "14", "15"], - "rainy": ["9", "10", "11", "12"], - "snowy": ["22", "23", "24", "25", "26", "27"], - "snowy-rainy": ["16", "17", "18"], - "sunny": ["0", "1"], - "windy": [], - "windy-variant": [], - "exceptional": [], -} - -DEFAULT_NAME = "Met Office" - -VISIBILITY_CLASSES = { - "VP": "<1", - "PO": "1-4", - "MO": "4-10", - "GO": "10-20", - "VG": "20-40", - "EX": ">40", -} - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=35) - -# Sensor types are defined like: Name, units +# Sensor types are defined as: +# variable -> [0]title, [1]device_class, [2]units, [3]icon, [4]enabled_by_default SENSOR_TYPES = { - "name": ["Station Name", None], - "weather": ["Weather", None], - "temperature": ["Temperature", TEMP_CELSIUS], - "feels_like_temperature": ["Feels Like Temperature", TEMP_CELSIUS], - "wind_speed": ["Wind Speed", SPEED_MILES_PER_HOUR], - "wind_direction": ["Wind Direction", None], - "wind_gust": ["Wind Gust", SPEED_MILES_PER_HOUR], - "visibility": ["Visibility", None], - "visibility_distance": ["Visibility Distance", LENGTH_KILOMETERS], - "uv": ["UV", UV_INDEX], - "precipitation": ["Probability of Precipitation", UNIT_PERCENTAGE], - "humidity": ["Humidity", UNIT_PERCENTAGE], + "name": ["Station Name", None, None, "mdi:label-outline", False], + "weather": [ + "Weather", + None, + None, + "mdi:weather-sunny", # but will adapt to current conditions + True, + ], + "temperature": ["Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None, True], + "feels_like_temperature": [ + "Feels Like Temperature", + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + None, + False, + ], + "wind_speed": [ + "Wind Speed", + None, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", + True, + ], + "wind_direction": ["Wind Direction", None, None, "mdi:compass-outline", False], + "wind_gust": ["Wind Gust", None, SPEED_MILES_PER_HOUR, "mdi:weather-windy", False], + "visibility": ["Visibility", None, None, "mdi:eye", False], + "visibility_distance": [ + "Visibility Distance", + None, + LENGTH_KILOMETERS, + "mdi:eye", + False, + ], + "uv": ["UV Index", None, UV_INDEX, "mdi:weather-sunny-alert", True], + "precipitation": [ + "Probability of Precipitation", + None, + UNIT_PERCENTAGE, + "mdi:weather-rainy", + True, + ], + "humidity": ["Humidity", DEVICE_CLASS_HUMIDITY, UNIT_PERCENTAGE, None, False], } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Inclusive( - CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.latitude, - vol.Inclusive( - CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.longitude, - } -) +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigType, async_add_entities +) -> None: + """Set up the Met Office weather sensor platform.""" + hass_data = hass.data[DOMAIN][entry.entry_id] -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Met Office sensor platform.""" - api_key = config.get(CONF_API_KEY) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - name = config.get(CONF_NAME) - - datapoint = dp.connection(api_key=api_key) - - if None in (latitude, longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return - - try: - site = datapoint.get_nearest_site(latitude=latitude, longitude=longitude) - except dp.exceptions.APIException as err: - _LOGGER.error("Received error from Met Office Datapoint: %s", err) - return - - if not site: - _LOGGER.error("Unable to get nearest Met Office forecast site") - return - - data = MetOfficeCurrentData(hass, datapoint, site) - data.update() - if data.data is None: - return - - sensors = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - sensors.append(MetOfficeCurrentSensor(site, data, variable, name)) - - add_entities(sensors, True) + async_add_entities( + [ + MetOfficeCurrentSensor(entry.data, hass_data, sensor_type) + for sensor_type in SENSOR_TYPES + ], + False, + ) class MetOfficeCurrentSensor(Entity): - """Implementation of a Met Office current sensor.""" + """Implementation of a Met Office current weather condition sensor.""" - def __init__(self, site, data, condition, name): + def __init__(self, entry_data, hass_data, sensor_type): """Initialize the sensor.""" - self._condition = condition - self.data = data - self._name = name - self.site = site + self._data = hass_data[METOFFICE_DATA] + self._coordinator = hass_data[METOFFICE_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}" + + self.metoffice_site_id = None + self.metoffice_site_name = None + self.metoffice_now = None @property def name(self): """Return the name of the sensor.""" - return f"{self._name} {SENSOR_TYPES[self._condition][0]}" + return self._name + + @property + def unique_id(self): + """Return the unique of the sensor.""" + return self._unique_id @property def state(self): """Return the state of the sensor.""" - if self._condition == "visibility_distance" and hasattr( - self.data.data, "visibility" + value = None + + if self._type == "visibility_distance" and hasattr( + self.metoffice_now, "visibility" ): - return VISIBILITY_CLASSES.get(self.data.data.visibility.value) - if hasattr(self.data.data, self._condition): - variable = getattr(self.data.data, self._condition) - if self._condition == "weather": - return [ - k - for k, v in CONDITION_CLASSES.items() - if self.data.data.weather.value in v - ][0] - return variable.value - return None + value = VISIBILITY_DISTANCE_CLASSES.get(self.metoffice_now.visibility.value) + + if self._type == "visibility" and hasattr(self.metoffice_now, "visibility"): + value = VISIBILITY_CLASSES.get(self.metoffice_now.visibility.value) + + elif self._type == "weather" and hasattr(self.metoffice_now, self._type): + value = [ + k + for k, v in CONDITION_CLASSES.items() + if self.metoffice_now.weather.value in v + ][0] + + elif hasattr(self.metoffice_now, self._type): + value = getattr(self.metoffice_now, self._type) + + if not isinstance(value, int): + value = value.value + + return value @property def unit_of_measurement(self): """Return the unit of measurement.""" - return SENSOR_TYPES[self._condition][1] + return SENSOR_TYPES[self._type][2] + + @property + def icon(self): + """Return the icon for the entity card.""" + value = SENSOR_TYPES[self._type][3] + if self._type == "weather": + value = self.state + if value is None: + value = "sunny" + elif value == "partlycloudy": + value = "partly-cloudy" + value = f"mdi:weather-{value}" + + return value + + @property + def device_class(self): + """Return the device class of the sensor.""" + return SENSOR_TYPES[self._type][1] @property def device_state_attributes(self): """Return the state attributes of the device.""" - attr = {} - attr[ATTR_ATTRIBUTION] = ATTRIBUTION - attr[ATTR_LAST_UPDATE] = self.data.data.date - attr[ATTR_SENSOR_ID] = self._condition - attr[ATTR_SITE_ID] = self.site.id - attr[ATTR_SITE_NAME] = self.site.name - return attr + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_LAST_UPDATE: self.metoffice_now.date if self.metoffice_now else None, + 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, + } - def update(self): - """Update current conditions.""" - self.data.update() + 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() -class MetOfficeCurrentData: - """Get data from Datapoint.""" + @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() - def __init__(self, hass, datapoint, site): - """Initialize the data object.""" - self._datapoint = datapoint - self._site = site - self.data = None + @property + def should_poll(self) -> bool: + """Entities do not individually poll.""" + return False - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from Datapoint.""" - try: - forecast = self._datapoint.get_forecast_for_site(self._site.id, "3hourly") - self.data = forecast.now() - except (ValueError, dp.exceptions.APIException) as err: - _LOGGER.error("Check Met Office %s", err.args) - self.data = None + @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 diff --git a/homeassistant/components/metoffice/strings.json b/homeassistant/components/metoffice/strings.json new file mode 100644 index 00000000000..74d8b16542a --- /dev/null +++ b/homeassistant/components/metoffice/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "description": "The latitude and longitude will be used to find the closest weather station.", + "title": "Connect to the UK Met Office", + "data": { + "api_key": "Met Office DataPoint API key", + "latitude": "Latitude", + "longitude": "Longitude" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/metoffice/translations/en.json b/homeassistant/components/metoffice/translations/en.json new file mode 100644 index 00000000000..65a28379e4c --- /dev/null +++ b/homeassistant/components/metoffice/translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "Met Office DataPoint API key", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Friendly name" + }, + "description": "The latitude and longitude will be used to find the closest weather station.", + "title": "Connect to the UK Met Office" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 09350588d46..f94c2a4ad7a 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -1,127 +1,161 @@ """Support for UK Met Office weather service.""" + import logging -import datapoint as dp -import voluptuous as vol +from homeassistant.components.weather import WeatherEntity +from homeassistant.const import LENGTH_KILOMETERS, TEMP_CELSIUS +from homeassistant.core import callback +from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, - TEMP_CELSIUS, +from .const import ( + ATTRIBUTION, + CONDITION_CLASSES, + DEFAULT_NAME, + DOMAIN, + METOFFICE_COORDINATOR, + METOFFICE_DATA, + METOFFICE_NAME, + VISIBILITY_CLASSES, + VISIBILITY_DISTANCE_CLASSES, ) -from homeassistant.helpers import config_validation as cv - -from .sensor import ATTRIBUTION, CONDITION_CLASSES, MetOfficeCurrentData _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = "Met Office" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Inclusive( - CONF_LATITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.latitude, - vol.Inclusive( - CONF_LONGITUDE, "coordinates", "Latitude and longitude must exist together" - ): cv.longitude, - } -) +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigType, async_add_entities +) -> None: + """Set up the Met Office weather sensor platform.""" + hass_data = hass.data[DOMAIN][entry.entry_id] - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Met Office weather platform.""" - name = config.get(CONF_NAME) - datapoint = dp.connection(api_key=config.get(CONF_API_KEY)) - - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - if None in (latitude, longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") - return - - try: - site = datapoint.get_nearest_site(latitude=latitude, longitude=longitude) - except dp.exceptions.APIException as err: - _LOGGER.error("Received error from Met Office Datapoint: %s", err) - return - - if not site: - _LOGGER.error("Unable to get nearest Met Office forecast site") - return - - data = MetOfficeCurrentData(hass, datapoint, site) - try: - data.update() - except (ValueError, dp.exceptions.APIException) as err: - _LOGGER.error("Received error from Met Office Datapoint: %s", err) - return - - add_entities([MetOfficeWeather(site, data, name)], True) + async_add_entities( + [MetOfficeWeather(entry.data, hass_data,)], False, + ) class MetOfficeWeather(WeatherEntity): """Implementation of a Met Office weather condition.""" - def __init__(self, site, data, name): - """Initialise the platform with a data instance and site.""" - self._name = name - self.data = data - self.site = site + def __init__(self, entry_data, hass_data): + """Initialise the platform with a data instance.""" + self._data = hass_data[METOFFICE_DATA] + self._coordinator = hass_data[METOFFICE_COORDINATOR] - def update(self): - """Update current conditions.""" - self.data.update() + self._name = f"{DEFAULT_NAME} {hass_data[METOFFICE_NAME]}" + self._unique_id = f"{self._data.latitude}_{self._data.longitude}" + + self.metoffice_now = None @property def name(self): """Return the name of the sensor.""" - return f"{self._name} {self.site.name}" + return self._name + + @property + def unique_id(self): + """Return the unique of the sensor.""" + return self._unique_id @property def condition(self): """Return the current condition.""" - return [ - k for k, v in CONDITION_CLASSES.items() if self.data.data.weather.value in v - ][0] + return ( + [ + k + for k, v in CONDITION_CLASSES.items() + if self.metoffice_now.weather.value in v + ][0] + if self.metoffice_now + else None + ) @property def temperature(self): """Return the platform temperature.""" - return self.data.data.temperature.value + return ( + self.metoffice_now.temperature.value + if self.metoffice_now and self.metoffice_now.temperature + else None + ) @property def temperature_unit(self): """Return the unit of measurement.""" return TEMP_CELSIUS + @property + 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)}" + return _visibility + + @property + def visibility_unit(self): + """Return the unit of measurement.""" + return LENGTH_KILOMETERS + @property def pressure(self): """Return the mean sea-level pressure.""" - return None + return ( + self.metoffice_now.pressure.value + if self.metoffice_now and self.metoffice_now.pressure + else None + ) @property def humidity(self): """Return the relative humidity.""" - return self.data.data.humidity.value + return ( + self.metoffice_now.humidity.value + if self.metoffice_now and self.metoffice_now.humidity + else None + ) @property def wind_speed(self): """Return the wind speed.""" - return self.data.data.wind_speed.value + return ( + self.metoffice_now.wind_speed.value + if self.metoffice_now and self.metoffice_now.wind_speed + else None + ) @property def wind_bearing(self): """Return the wind bearing.""" - return self.data.data.wind_direction.value + return ( + self.metoffice_now.wind_direction.value + if self.metoffice_now and self.metoffice_now.wind_direction + else None + ) @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 diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 80e0d496abf..644daf61c32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -94,6 +94,7 @@ FLOWS = [ "melcloud", "met", "meteo_france", + "metoffice", "mikrotik", "mill", "minecraft_server", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a91efa9b8db..5fd50260e54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -202,6 +202,9 @@ coronavirus==1.1.1 # homeassistant.components.datadog datadog==0.15.0 +# homeassistant.components.metoffice +datapoint==0.9.5 + # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect diff --git a/tests/components/metoffice/__init__.py b/tests/components/metoffice/__init__.py new file mode 100644 index 00000000000..fdefc3d4786 --- /dev/null +++ b/tests/components/metoffice/__init__.py @@ -0,0 +1 @@ +"""Tests for the metoffice component.""" diff --git a/tests/components/metoffice/conftest.py b/tests/components/metoffice/conftest.py new file mode 100644 index 00000000000..9538c7a8668 --- /dev/null +++ b/tests/components/metoffice/conftest.py @@ -0,0 +1,22 @@ +"""Fixtures for Met Office weather integration tests.""" +from datapoint.exceptions import APIException +import pytest + +from tests.async_mock import patch + + +@pytest.fixture() +def mock_simple_manager_fail(): + """Mock datapoint Manager with default values for testing in config_flow.""" + with patch("datapoint.Manager") as mock_manager: + instance = mock_manager.return_value + instance.get_nearest_forecast_site.side_effect = APIException() + instance.get_forecast_for_site.side_effect = APIException() + instance.latitude = None + instance.longitude = None + instance.site = None + instance.site_id = None + instance.site_name = None + instance.now = None + + yield mock_manager diff --git a/tests/components/metoffice/const.py b/tests/components/metoffice/const.py new file mode 100644 index 00000000000..5d8d781b042 --- /dev/null +++ b/tests/components/metoffice/const.py @@ -0,0 +1,58 @@ +"""Helpers for testing Met Office DataPoint.""" + +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME + +DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S%z" +TEST_DATETIME_STRING = "2020-04-25 12:00:00+0000" + +TEST_API_KEY = "test-metoffice-api-key" + +TEST_LATITUDE_WAVERTREE = 53.38374 +TEST_LONGITUDE_WAVERTREE = -2.90929 +TEST_SITE_NAME_WAVERTREE = "Wavertree" + +TEST_LATITUDE_KINGSLYNN = 52.75556 +TEST_LONGITUDE_KINGSLYNN = 0.44231 +TEST_SITE_NAME_KINGSLYNN = "King's Lynn" + +METOFFICE_CONFIG_WAVERTREE = { + CONF_API_KEY: TEST_API_KEY, + CONF_LATITUDE: TEST_LATITUDE_WAVERTREE, + CONF_LONGITUDE: TEST_LONGITUDE_WAVERTREE, + CONF_NAME: TEST_SITE_NAME_WAVERTREE, +} + +METOFFICE_CONFIG_KINGSLYNN = { + CONF_API_KEY: TEST_API_KEY, + CONF_LATITUDE: TEST_LATITUDE_KINGSLYNN, + CONF_LONGITUDE: TEST_LONGITUDE_KINGSLYNN, + CONF_NAME: TEST_SITE_NAME_KINGSLYNN, +} + +KINGSLYNN_SENSOR_RESULTS = { + "weather": ("weather", "sunny"), + "visibility": ("visibility", "Very Good"), + "visibility_distance": ("visibility_distance", "20-40"), + "temperature": ("temperature", "14"), + "feels_like_temperature": ("feels_like_temperature", "13"), + "uv": ("uv_index", "6"), + "precipitation": ("probability_of_precipitation", "0"), + "wind_direction": ("wind_direction", "E"), + "wind_gust": ("wind_gust", "7"), + "wind_speed": ("wind_speed", "2"), + "humidity": ("humidity", "60"), +} + +WAVERTREE_SENSOR_RESULTS = { + "weather": ("weather", "sunny"), + "visibility": ("visibility", "Good"), + "visibility_distance": ("visibility_distance", "10-20"), + "temperature": ("temperature", "17"), + "feels_like_temperature": ("feels_like_temperature", "14"), + "uv": ("uv_index", "5"), + "precipitation": ("probability_of_precipitation", "0"), + "wind_direction": ("wind_direction", "SSE"), + "wind_gust": ("wind_gust", "16"), + "wind_speed": ("wind_speed", "9"), + "humidity": ("humidity", "50"), +} diff --git a/tests/components/metoffice/test_config_flow.py b/tests/components/metoffice/test_config_flow.py new file mode 100644 index 00000000000..6916e949b1c --- /dev/null +++ b/tests/components/metoffice/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the National Weather Service (NWS) config flow.""" +import json + +from homeassistant import config_entries, setup +from homeassistant.components.metoffice.const import DOMAIN + +from .const import ( + METOFFICE_CONFIG_WAVERTREE, + TEST_API_KEY, + TEST_LATITUDE_WAVERTREE, + TEST_LONGITUDE_WAVERTREE, + TEST_SITE_NAME_WAVERTREE, +) + +from tests.async_mock import patch +from tests.common import MockConfigEntry, load_fixture + + +async def test_form(hass, requests_mock): + """Test we get the form.""" + hass.config.latitude = TEST_LATITUDE_WAVERTREE + hass.config.longitude = TEST_LONGITUDE_WAVERTREE + + # all metoffice test data encapsulated in here + mock_json = json.loads(load_fixture("metoffice.json")) + all_sites = json.dumps(mock_json["all_sites"]) + requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) + + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.metoffice.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.metoffice.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_key": TEST_API_KEY} + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_SITE_NAME_WAVERTREE + assert result2["data"] == { + "api_key": TEST_API_KEY, + "latitude": TEST_LATITUDE_WAVERTREE, + "longitude": TEST_LONGITUDE_WAVERTREE, + "name": TEST_SITE_NAME_WAVERTREE, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_already_configured(hass, requests_mock): + """Test we handle duplicate entries.""" + hass.config.latitude = TEST_LATITUDE_WAVERTREE + hass.config.longitude = TEST_LONGITUDE_WAVERTREE + + # all metoffice test data encapsulated in here + mock_json = json.loads(load_fixture("metoffice.json")) + + all_sites = json.dumps(mock_json["all_sites"]) + + 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="", + ) + + MockConfigEntry( + domain=DOMAIN, + unique_id=f"{TEST_LATITUDE_WAVERTREE}_{TEST_LONGITUDE_WAVERTREE}", + data=METOFFICE_CONFIG_WAVERTREE, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=METOFFICE_CONFIG_WAVERTREE, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_form_cannot_connect(hass, requests_mock): + """Test we handle cannot connect error.""" + hass.config.latitude = TEST_LATITUDE_WAVERTREE + hass.config.longitude = TEST_LONGITUDE_WAVERTREE + + requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text="") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_key": TEST_API_KEY}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass, mock_simple_manager_fail): + """Test we handle unknown error.""" + mock_instance = mock_simple_manager_fail.return_value + mock_instance.get_nearest_forecast_site.side_effect = ValueError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_key": TEST_API_KEY}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py new file mode 100644 index 00000000000..70a66a3093c --- /dev/null +++ b/tests/components/metoffice/test_sensor.py @@ -0,0 +1,117 @@ +"""The tests for the Met Office sensor component.""" +from datetime import datetime, timezone +import json + +from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN + +from .const import ( + DATETIME_FORMAT, + KINGSLYNN_SENSOR_RESULTS, + METOFFICE_CONFIG_KINGSLYNN, + METOFFICE_CONFIG_WAVERTREE, + TEST_DATETIME_STRING, + TEST_SITE_NAME_KINGSLYNN, + TEST_SITE_NAME_WAVERTREE, + WAVERTREE_SENSOR_RESULTS, +) + +from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry, load_fixture + + +@patch( + "datapoint.Forecast.datetime.datetime", + Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))), +) +async def test_one_sensor_site_running(hass, requests_mock): + """Test the Met Office sensor platform.""" + + # 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"]) + + 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, + ) + + 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() + + running_sensor_ids = hass.states.async_entity_ids("sensor") + assert len(running_sensor_ids) > 0 + for running_id in running_sensor_ids: + sensor = hass.states.get(running_id) + sensor_id = sensor.attributes.get("sensor_id") + sensor_name, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] + + assert sensor.state == sensor_value + assert ( + sensor.attributes.get("last_update").strftime(DATETIME_FORMAT) + == TEST_DATETIME_STRING + ) + assert sensor.attributes.get("site_id") == "354107" + assert sensor.attributes.get("site_name") == TEST_SITE_NAME_WAVERTREE + assert sensor.attributes.get("attribution") == ATTRIBUTION + + +@patch( + "datapoint.Forecast.datetime.datetime", + Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))), +) +async def test_two_sensor_sites_running(hass, requests_mock): + """Test we handle two sets of sensors running for two different sites.""" + + # 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"]) + kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) + + 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/322380?res=3hourly", text=kingslynn_hourly + ) + + entry = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE,) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + entry2 = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_KINGSLYNN,) + entry2.add_to_hass(hass) + await hass.config_entries.async_setup(entry2.entry_id) + await hass.async_block_till_done() + + running_sensor_ids = hass.states.async_entity_ids("sensor") + assert len(running_sensor_ids) > 0 + for running_id in running_sensor_ids: + sensor = hass.states.get(running_id) + sensor_id = sensor.attributes.get("sensor_id") + if sensor.attributes.get("site_id") == "354107": + sensor_name, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] + assert sensor.state == sensor_value + assert ( + sensor.attributes.get("last_update").strftime(DATETIME_FORMAT) + == TEST_DATETIME_STRING + ) + assert sensor.attributes.get("sensor_id") == sensor_id + assert sensor.attributes.get("site_id") == "354107" + assert sensor.attributes.get("site_name") == TEST_SITE_NAME_WAVERTREE + assert sensor.attributes.get("attribution") == ATTRIBUTION + + else: + sensor_name, sensor_value = KINGSLYNN_SENSOR_RESULTS[sensor_id] + assert sensor.state == sensor_value + assert ( + sensor.attributes.get("last_update").strftime(DATETIME_FORMAT) + == TEST_DATETIME_STRING + ) + assert sensor.attributes.get("sensor_id") == sensor_id + assert sensor.attributes.get("site_id") == "322380" + assert sensor.attributes.get("site_name") == TEST_SITE_NAME_KINGSLYNN + assert sensor.attributes.get("attribution") == ATTRIBUTION diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py new file mode 100644 index 00000000000..08440798f47 --- /dev/null +++ b/tests/components/metoffice/test_weather.py @@ -0,0 +1,159 @@ +"""The tests for the Met Office sensor component.""" +from datetime import datetime, timedelta, timezone +import json + +from homeassistant.components.metoffice.const import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.util import utcnow + +from .const import ( + METOFFICE_CONFIG_KINGSLYNN, + METOFFICE_CONFIG_WAVERTREE, + WAVERTREE_SENSOR_RESULTS, +) + +from tests.async_mock import Mock, patch +from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture + + +@patch( + "datapoint.Forecast.datetime.datetime", + Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))), +) +async def test_site_cannot_connect(hass, requests_mock): + """Test we handle cannot connect error.""" + + requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text="") + requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="") + + 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 hass.states.get("weather.met_office_wavertree") is None + for sensor_id in WAVERTREE_SENSOR_RESULTS: + sensor_name, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] + sensor = hass.states.get(f"sensor.wavertree_{sensor_name}") + assert sensor is None + + +@patch( + "datapoint.Forecast.datetime.datetime", + Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))), +) +async def test_site_cannot_update(hass, requests_mock): + """Test we handle cannot connect error.""" + + # 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"]) + + 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 + ) + + 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() + + entity = hass.states.get("weather.met_office_wavertree") + assert entity + + requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", 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") + assert entity.state == STATE_UNAVAILABLE + + +@patch( + "datapoint.Forecast.datetime.datetime", + Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))), +) +async def test_one_weather_site_running(hass, requests_mock): + """Test the Met Office weather platform.""" + + # 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"]) + + 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, + ) + + 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() + + # Wavertree weather platform expected results + entity = hass.states.get("weather.met_office_wavertree") + assert entity + + assert entity.state == "sunny" + assert entity.attributes.get("temperature") == 17 + 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 + + +@patch( + "datapoint.Forecast.datetime.datetime", + Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))), +) +async def test_two_weather_sites_running(hass, requests_mock): + """Test we handle two different weather sites both running.""" + + # 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"]) + kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) + + 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/322380?res=3hourly", text=kingslynn_hourly + ) + + entry = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE,) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + entry2 = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_KINGSLYNN,) + entry2.add_to_hass(hass) + 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") + assert entity + + assert entity.state == "sunny" + assert entity.attributes.get("temperature") == 17 + 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 + + # King's Lynn weather platform expected results + entity = hass.states.get("weather.met_office_king_s_lynn") + assert entity + + assert entity.state == "sunny" + assert entity.attributes.get("temperature") == 14 + assert entity.attributes.get("wind_speed") == 2 + assert entity.attributes.get("wind_bearing") == "E" + assert entity.attributes.get("visibility") == "Very Good - 20-40" + assert entity.attributes.get("humidity") == 60 diff --git a/tests/fixtures/metoffice.json b/tests/fixtures/metoffice.json new file mode 100644 index 00000000000..c2b8707ca7a --- /dev/null +++ b/tests/fixtures/metoffice.json @@ -0,0 +1,1499 @@ +{ + "all_sites": { + "Locations": { + "Location": [ + { + "elevation": "47.0", + "id": "354107", + "latitude": "53.3986", + "longitude": "-2.9256", + "name": "Wavertree", + "region": "nw", + "unitaryAuthArea": "Merseyside" + }, + { + "elevation": "5.0", + "id": "322380", + "latitude": "52.7561", + "longitude": "0.4019", + "name": "King's Lynn", + "region": "ee", + "unitaryAuthArea": "Norfolk" + } + ] + } + }, + "wavertree_hourly": { + "SiteRep": { + "Wx": { + "Param": [ + { + "name": "F", + "units": "C", + "$": "Feels Like Temperature" + }, + { + "name": "G", + "units": "mph", + "$": "Wind Gust" + }, + { + "name": "H", + "units": "%", + "$": "Screen Relative Humidity" + }, + { + "name": "T", + "units": "C", + "$": "Temperature" + }, + { + "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": "Pp", + "units": "%", + "$": "Precipitation Probability" + } + ] + }, + "DV": { + "dataDate": "2020-04-25T08:00:00Z", + "type": "Forecast", + "Location": { + "i": "354107", + "lat": "53.3986", + "lon": "-2.9256", + "name": "WAVERTREE", + "country": "ENGLAND", + "continent": "EUROPE", + "elevation": "47.0", + "Period": [ + { + "type": "Day", + "value": "2020-04-25Z", + "Rep": [ + { + "D": "SE", + "F": "7", + "G": "25", + "H": "63", + "Pp": "0", + "S": "9", + "T": "9", + "V": "VG", + "W": "0", + "U": "0", + "$": "180" + }, + { + "D": "ESE", + "F": "4", + "G": "22", + "H": "76", + "Pp": "0", + "S": "11", + "T": "7", + "V": "GO", + "W": "1", + "U": "1", + "$": "360" + }, + { + "D": "SSE", + "F": "8", + "G": "18", + "H": "70", + "Pp": "0", + "S": "9", + "T": "10", + "V": "MO", + "W": "1", + "U": "3", + "$": "540" + }, + { + "D": "SSE", + "F": "14", + "G": "16", + "H": "50", + "Pp": "0", + "S": "9", + "T": "17", + "V": "GO", + "W": "1", + "U": "5", + "$": "720" + }, + { + "D": "S", + "F": "17", + "G": "9", + "H": "43", + "Pp": "1", + "S": "4", + "T": "19", + "V": "GO", + "W": "1", + "U": "2", + "$": "900" + }, + { + "D": "WNW", + "F": "15", + "G": "13", + "H": "55", + "Pp": "2", + "S": "7", + "T": "17", + "V": "GO", + "W": "3", + "U": "1", + "$": "1080" + }, + { + "D": "NW", + "F": "14", + "G": "7", + "H": "64", + "Pp": "1", + "S": "2", + "T": "14", + "V": "GO", + "W": "2", + "U": "0", + "$": "1260" + } + ] + }, + { + "type": "Day", + "value": "2020-04-26Z", + "Rep": [ + { + "D": "WSW", + "F": "13", + "G": "4", + "H": "73", + "Pp": "1", + "S": "2", + "T": "13", + "V": "GO", + "W": "2", + "U": "0", + "$": "0" + }, + { + "D": "WNW", + "F": "12", + "G": "9", + "H": "77", + "Pp": "2", + "S": "4", + "T": "12", + "V": "GO", + "W": "2", + "U": "0", + "$": "180" + }, + { + "D": "NW", + "F": "10", + "G": "9", + "H": "82", + "Pp": "5", + "S": "4", + "T": "11", + "V": "MO", + "W": "7", + "U": "1", + "$": "360" + }, + { + "D": "WNW", + "F": "11", + "G": "7", + "H": "79", + "Pp": "5", + "S": "4", + "T": "12", + "V": "MO", + "W": "7", + "U": "3", + "$": "540" + }, + { + "D": "WNW", + "F": "10", + "G": "18", + "H": "78", + "Pp": "6", + "S": "9", + "T": "12", + "V": "MO", + "W": "7", + "U": "4", + "$": "720" + }, + { + "D": "NW", + "F": "10", + "G": "18", + "H": "71", + "Pp": "5", + "S": "9", + "T": "12", + "V": "GO", + "W": "7", + "U": "2", + "$": "900" + }, + { + "D": "NW", + "F": "9", + "G": "16", + "H": "68", + "Pp": "9", + "S": "9", + "T": "11", + "V": "VG", + "W": "7", + "U": "1", + "$": "1080" + }, + { + "D": "NW", + "F": "8", + "G": "11", + "H": "68", + "Pp": "9", + "S": "7", + "T": "10", + "V": "VG", + "W": "8", + "U": "0", + "$": "1260" + } + ] + }, + { + "type": "Day", + "value": "2020-04-27Z", + "Rep": [ + { + "D": "WNW", + "F": "8", + "G": "9", + "H": "72", + "Pp": "11", + "S": "4", + "T": "9", + "V": "VG", + "W": "8", + "U": "0", + "$": "0" + }, + { + "D": "WNW", + "F": "7", + "G": "11", + "H": "77", + "Pp": "12", + "S": "7", + "T": "8", + "V": "VG", + "W": "7", + "U": "0", + "$": "180" + }, + { + "D": "NW", + "F": "7", + "G": "9", + "H": "80", + "Pp": "14", + "S": "4", + "T": "8", + "V": "GO", + "W": "7", + "U": "1", + "$": "360" + }, + { + "D": "NW", + "F": "7", + "G": "18", + "H": "73", + "Pp": "6", + "S": "9", + "T": "9", + "V": "VG", + "W": "3", + "U": "2", + "$": "540" + }, + { + "D": "NW", + "F": "8", + "G": "20", + "H": "59", + "Pp": "4", + "S": "9", + "T": "10", + "V": "VG", + "W": "3", + "U": "3", + "$": "720" + }, + { + "D": "NW", + "F": "8", + "G": "20", + "H": "58", + "Pp": "1", + "S": "9", + "T": "10", + "V": "VG", + "W": "1", + "U": "2", + "$": "900" + }, + { + "D": "NW", + "F": "8", + "G": "16", + "H": "57", + "Pp": "1", + "S": "7", + "T": "10", + "V": "VG", + "W": "1", + "U": "1", + "$": "1080" + }, + { + "D": "NW", + "F": "8", + "G": "11", + "H": "67", + "Pp": "1", + "S": "4", + "T": "9", + "V": "VG", + "W": "0", + "U": "0", + "$": "1260" + } + ] + }, + { + "type": "Day", + "value": "2020-04-28Z", + "Rep": [ + { + "D": "NNW", + "F": "7", + "G": "7", + "H": "80", + "Pp": "2", + "S": "4", + "T": "8", + "V": "VG", + "W": "0", + "U": "0", + "$": "0" + }, + { + "D": "W", + "F": "6", + "G": "7", + "H": "86", + "Pp": "3", + "S": "4", + "T": "7", + "V": "GO", + "W": "0", + "U": "0", + "$": "180" + }, + { + "D": "S", + "F": "5", + "G": "9", + "H": "86", + "Pp": "5", + "S": "4", + "T": "6", + "V": "GO", + "W": "1", + "U": "1", + "$": "360" + }, + { + "D": "ENE", + "F": "7", + "G": "13", + "H": "72", + "Pp": "6", + "S": "7", + "T": "9", + "V": "GO", + "W": "3", + "U": "3", + "$": "540" + }, + { + "D": "ENE", + "F": "10", + "G": "16", + "H": "57", + "Pp": "10", + "S": "7", + "T": "11", + "V": "GO", + "W": "7", + "U": "4", + "$": "720" + }, + { + "D": "N", + "F": "11", + "G": "16", + "H": "58", + "Pp": "10", + "S": "7", + "T": "12", + "V": "GO", + "W": "7", + "U": "2", + "$": "900" + }, + { + "D": "N", + "F": "10", + "G": "16", + "H": "63", + "Pp": "10", + "S": "7", + "T": "11", + "V": "VG", + "W": "7", + "U": "1", + "$": "1080" + }, + { + "D": "NNE", + "F": "9", + "G": "11", + "H": "72", + "Pp": "9", + "S": "4", + "T": "10", + "V": "VG", + "W": "7", + "U": "0", + "$": "1260" + } + ] + }, + { + "type": "Day", + "value": "2020-04-29Z", + "Rep": [ + { + "D": "E", + "F": "8", + "G": "9", + "H": "79", + "Pp": "6", + "S": "4", + "T": "9", + "V": "VG", + "W": "7", + "U": "0", + "$": "0" + }, + { + "D": "SSE", + "F": "7", + "G": "11", + "H": "81", + "Pp": "3", + "S": "7", + "T": "8", + "V": "GO", + "W": "2", + "U": "0", + "$": "180" + }, + { + "D": "SE", + "F": "5", + "G": "16", + "H": "86", + "Pp": "9", + "S": "9", + "T": "8", + "V": "GO", + "W": "7", + "U": "1", + "$": "360" + }, + { + "D": "SE", + "F": "8", + "G": "22", + "H": "74", + "Pp": "12", + "S": "11", + "T": "10", + "V": "GO", + "W": "7", + "U": "3", + "$": "540" + }, + { + "D": "SE", + "F": "10", + "G": "27", + "H": "72", + "Pp": "47", + "S": "13", + "T": "12", + "V": "GO", + "W": "12", + "U": "3", + "$": "720" + }, + { + "D": "SSE", + "F": "10", + "G": "29", + "H": "73", + "Pp": "59", + "S": "13", + "T": "13", + "V": "GO", + "W": "14", + "U": "2", + "$": "900" + }, + { + "D": "SSE", + "F": "10", + "G": "20", + "H": "69", + "Pp": "39", + "S": "11", + "T": "12", + "V": "VG", + "W": "10", + "U": "1", + "$": "1080" + }, + { + "D": "SSE", + "F": "9", + "G": "22", + "H": "79", + "Pp": "19", + "S": "13", + "T": "11", + "V": "GO", + "W": "7", + "U": "0", + "$": "1260" + } + ] + } + ] + } + } + } + }, + "wavertree_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": "354107", + "lat": "53.3986", + "lon": "-2.9256", + "name": "WAVERTREE", + "country": "ENGLAND", + "continent": "EUROPE", + "elevation": "47.0", + "Period": [ + { + "type": "Day", + "value": "2020-04-25Z", + "Rep": [ + { + "D": "SSE", + "Gn": "16", + "Hn": "50", + "PPd": "2", + "S": "9", + "V": "GO", + "Dm": "19", + "FDm": "18", + "W": "1", + "U": "5", + "$": "Day" + }, + { + "D": "WSW", + "Gm": "4", + "Hm": "73", + "PPn": "2", + "S": "2", + "V": "GO", + "Nm": "11", + "FNm": "11", + "W": "2", + "$": "Night" + } + ] + }, + { + "type": "Day", + "value": "2020-04-26Z", + "Rep": [ + { + "D": "WNW", + "Gn": "18", + "Hn": "78", + "PPd": "9", + "S": "9", + "V": "MO", + "Dm": "13", + "FDm": "11", + "W": "7", + "U": "4", + "$": "Day" + }, + { + "D": "WNW", + "Gm": "9", + "Hm": "72", + "PPn": "12", + "S": "4", + "V": "VG", + "Nm": "8", + "FNm": "7", + "W": "8", + "$": "Night" + } + ] + }, + { + "type": "Day", + "value": "2020-04-27Z", + "Rep": [ + { + "D": "NW", + "Gn": "20", + "Hn": "59", + "PPd": "14", + "S": "9", + "V": "VG", + "Dm": "11", + "FDm": "8", + "W": "3", + "U": "3", + "$": "Day" + }, + { + "D": "NNW", + "Gm": "7", + "Hm": "80", + "PPn": "3", + "S": "4", + "V": "VG", + "Nm": "6", + "FNm": "5", + "W": "0", + "$": "Night" + } + ] + }, + { + "type": "Day", + "value": "2020-04-28Z", + "Rep": [ + { + "D": "ENE", + "Gn": "16", + "Hn": "57", + "PPd": "10", + "S": "7", + "V": "GO", + "Dm": "12", + "FDm": "11", + "W": "7", + "U": "4", + "$": "Day" + }, + { + "D": "E", + "Gm": "9", + "Hm": "79", + "PPn": "9", + "S": "4", + "V": "VG", + "Nm": "7", + "FNm": "6", + "W": "7", + "$": "Night" + } + ] + }, + { + "type": "Day", + "value": "2020-04-29Z", + "Rep": [ + { + "D": "SE", + "Gn": "27", + "Hn": "72", + "PPd": "59", + "S": "13", + "V": "GO", + "Dm": "13", + "FDm": "10", + "W": "12", + "U": "3", + "$": "Day" + }, + { + "D": "SSE", + "Gm": "18", + "Hm": "85", + "PPn": "19", + "S": "11", + "V": "VG", + "Nm": "8", + "FNm": "6", + "W": "7", + "$": "Night" + } + ] + } + ] + } + } + } + }, + "kingslynn_hourly": { + "SiteRep": { + "Wx": { + "Param": [ + { + "name": "F", + "units": "C", + "$": "Feels Like Temperature" + }, + { + "name": "G", + "units": "mph", + "$": "Wind Gust" + }, + { + "name": "H", + "units": "%", + "$": "Screen Relative Humidity" + }, + { + "name": "T", + "units": "C", + "$": "Temperature" + }, + { + "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": "Pp", + "units": "%", + "$": "Precipitation Probability" + } + ] + }, + "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": "SSE", + "F": "4", + "G": "9", + "H": "88", + "Pp": "7", + "S": "9", + "T": "7", + "V": "GO", + "W": "8", + "U": "0", + "$": "180" + }, + { + "D": "ESE", + "F": "5", + "G": "7", + "H": "86", + "Pp": "9", + "S": "4", + "T": "7", + "V": "GO", + "W": "8", + "U": "1", + "$": "360" + }, + { + "D": "ESE", + "F": "8", + "G": "4", + "H": "75", + "Pp": "9", + "S": "4", + "T": "9", + "V": "VG", + "W": "8", + "U": "3", + "$": "540" + }, + { + "D": "E", + "F": "13", + "G": "7", + "H": "60", + "Pp": "0", + "S": "2", + "T": "14", + "V": "VG", + "W": "1", + "U": "6", + "$": "720" + }, + { + "D": "NNW", + "F": "14", + "G": "9", + "H": "57", + "Pp": "0", + "S": "4", + "T": "15", + "V": "VG", + "W": "1", + "U": "3", + "$": "900" + }, + { + "D": "ENE", + "F": "14", + "G": "9", + "H": "58", + "Pp": "0", + "S": "4", + "T": "14", + "V": "VG", + "W": "1", + "U": "1", + "$": "1080" + }, + { + "D": "SE", + "F": "8", + "G": "18", + "H": "76", + "Pp": "0", + "S": "9", + "T": "10", + "V": "VG", + "W": "0", + "U": "0", + "$": "1260" + } + ] + }, + { + "type": "Day", + "value": "2020-04-26Z", + "Rep": [ + { + "D": "SSE", + "F": "5", + "G": "16", + "H": "84", + "Pp": "0", + "S": "7", + "T": "7", + "V": "VG", + "W": "0", + "U": "0", + "$": "0" + }, + { + "D": "S", + "F": "4", + "G": "16", + "H": "89", + "Pp": "0", + "S": "7", + "T": "6", + "V": "GO", + "W": "0", + "U": "0", + "$": "180" + }, + { + "D": "S", + "F": "4", + "G": "16", + "H": "87", + "Pp": "0", + "S": "7", + "T": "7", + "V": "GO", + "W": "1", + "U": "1", + "$": "360" + }, + { + "D": "SSW", + "F": "11", + "G": "13", + "H": "69", + "Pp": "0", + "S": "9", + "T": "13", + "V": "VG", + "W": "1", + "U": "4", + "$": "540" + }, + { + "D": "SW", + "F": "15", + "G": "18", + "H": "50", + "Pp": "8", + "S": "9", + "T": "17", + "V": "VG", + "W": "1", + "U": "5", + "$": "720" + }, + { + "D": "SW", + "F": "16", + "G": "16", + "H": "47", + "Pp": "8", + "S": "7", + "T": "18", + "V": "VG", + "W": "7", + "U": "2", + "$": "900" + }, + { + "D": "SW", + "F": "15", + "G": "13", + "H": "56", + "Pp": "3", + "S": "7", + "T": "17", + "V": "VG", + "W": "3", + "U": "1", + "$": "1080" + }, + { + "D": "SW", + "F": "13", + "G": "11", + "H": "76", + "Pp": "4", + "S": "4", + "T": "13", + "V": "VG", + "W": "7", + "U": "0", + "$": "1260" + } + ] + }, + { + "type": "Day", + "value": "2020-04-27Z", + "Rep": [ + { + "D": "SSW", + "F": "10", + "G": "13", + "H": "75", + "Pp": "5", + "S": "7", + "T": "11", + "V": "GO", + "W": "7", + "U": "0", + "$": "0" + }, + { + "D": "W", + "F": "9", + "G": "13", + "H": "84", + "Pp": "9", + "S": "7", + "T": "10", + "V": "GO", + "W": "7", + "U": "0", + "$": "180" + }, + { + "D": "NW", + "F": "7", + "G": "16", + "H": "85", + "Pp": "50", + "S": "9", + "T": "9", + "V": "GO", + "W": "12", + "U": "1", + "$": "360" + }, + { + "D": "NW", + "F": "9", + "G": "11", + "H": "78", + "Pp": "36", + "S": "4", + "T": "10", + "V": "VG", + "W": "7", + "U": "3", + "$": "540" + }, + { + "D": "WNW", + "F": "11", + "G": "11", + "H": "66", + "Pp": "9", + "S": "4", + "T": "12", + "V": "VG", + "W": "7", + "U": "4", + "$": "720" + }, + { + "D": "W", + "F": "11", + "G": "13", + "H": "62", + "Pp": "9", + "S": "7", + "T": "13", + "V": "VG", + "W": "7", + "U": "2", + "$": "900" + }, + { + "D": "E", + "F": "11", + "G": "11", + "H": "64", + "Pp": "10", + "S": "7", + "T": "12", + "V": "VG", + "W": "7", + "U": "1", + "$": "1080" + }, + { + "D": "SE", + "F": "9", + "G": "13", + "H": "78", + "Pp": "9", + "S": "7", + "T": "10", + "V": "VG", + "W": "7", + "U": "0", + "$": "1260" + } + ] + }, + { + "type": "Day", + "value": "2020-04-28Z", + "Rep": [ + { + "D": "SE", + "F": "7", + "G": "13", + "H": "85", + "Pp": "9", + "S": "7", + "T": "9", + "V": "VG", + "W": "7", + "U": "0", + "$": "0" + }, + { + "D": "E", + "F": "7", + "G": "9", + "H": "91", + "Pp": "11", + "S": "4", + "T": "8", + "V": "GO", + "W": "7", + "U": "0", + "$": "180" + }, + { + "D": "ESE", + "F": "7", + "G": "9", + "H": "92", + "Pp": "12", + "S": "4", + "T": "8", + "V": "GO", + "W": "7", + "U": "1", + "$": "360" + }, + { + "D": "ESE", + "F": "9", + "G": "13", + "H": "77", + "Pp": "14", + "S": "7", + "T": "11", + "V": "GO", + "W": "7", + "U": "3", + "$": "540" + }, + { + "D": "ESE", + "F": "12", + "G": "16", + "H": "64", + "Pp": "14", + "S": "7", + "T": "13", + "V": "GO", + "W": "7", + "U": "3", + "$": "720" + }, + { + "D": "ESE", + "F": "12", + "G": "18", + "H": "66", + "Pp": "15", + "S": "9", + "T": "13", + "V": "GO", + "W": "7", + "U": "2", + "$": "900" + }, + { + "D": "SSE", + "F": "11", + "G": "13", + "H": "73", + "Pp": "15", + "S": "7", + "T": "12", + "V": "GO", + "W": "7", + "U": "1", + "$": "1080" + }, + { + "D": "SE", + "F": "9", + "G": "13", + "H": "81", + "Pp": "13", + "S": "7", + "T": "10", + "V": "GO", + "W": "7", + "U": "0", + "$": "1260" + } + ] + }, + { + "type": "Day", + "value": "2020-04-29Z", + "Rep": [ + { + "D": "SSE", + "F": "7", + "G": "13", + "H": "87", + "Pp": "11", + "S": "7", + "T": "9", + "V": "GO", + "W": "7", + "U": "0", + "$": "0" + }, + { + "D": "SSE", + "F": "7", + "G": "13", + "H": "91", + "Pp": "15", + "S": "7", + "T": "9", + "V": "GO", + "W": "8", + "U": "0", + "$": "180" + }, + { + "D": "ESE", + "F": "7", + "G": "13", + "H": "89", + "Pp": "8", + "S": "7", + "T": "9", + "V": "GO", + "W": "7", + "U": "1", + "$": "360" + }, + { + "D": "SSE", + "F": "10", + "G": "20", + "H": "75", + "Pp": "8", + "S": "11", + "T": "12", + "V": "VG", + "W": "7", + "U": "3", + "$": "540" + }, + { + "D": "S", + "F": "12", + "G": "22", + "H": "68", + "Pp": "11", + "S": "11", + "T": "14", + "V": "GO", + "W": "7", + "U": "3", + "$": "720" + }, + { + "D": "S", + "F": "12", + "G": "27", + "H": "68", + "Pp": "55", + "S": "13", + "T": "14", + "V": "GO", + "W": "12", + "U": "1", + "$": "900" + }, + { + "D": "SSE", + "F": "11", + "G": "22", + "H": "76", + "Pp": "34", + "S": "11", + "T": "13", + "V": "VG", + "W": "10", + "U": "1", + "$": "1080" + }, + { + "D": "SSE", + "F": "9", + "G": "20", + "H": "86", + "Pp": "20", + "S": "11", + "T": "11", + "V": "VG", + "W": "7", + "U": "0", + "$": "1260" + } + ] + } + ] + } + } + } + } +} \ No newline at end of file