Convert MetOffice to use UI for configuration (#34900)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Ian Harcombe 2020-06-15 11:02:25 +01:00 committed by GitHub
parent 9a867cbb75
commit c96458c7e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 2602 additions and 227 deletions

View file

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

View file

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

View file

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

View file

@ -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",
}

View file

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

View file

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

View file

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

View file

@ -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%]"
}
}
}

View file

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

View file

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

View file

@ -94,6 +94,7 @@ FLOWS = [
"melcloud",
"met",
"meteo_france",
"metoffice",
"mikrotik",
"mill",
"minecraft_server",

View file

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

View file

@ -0,0 +1 @@
"""Tests for the metoffice component."""

View file

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

View file

@ -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"),
}

View file

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

View file

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

View file

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

1499
tests/fixtures/metoffice.json vendored Normal file

File diff suppressed because it is too large Load diff