Swedish weather institute weather component (#16717)

* SMHI Component

* Clean up typos

* Fixed default values first config to home location (tests will follow)

* Fixed tests and removed unused function

* Minor fixup after comments from @kane610

* add support for precipitation in forecast

* Removed old async_step_init not needed.
This commit is contained in:
Tomas Hellström 2018-10-08 23:54:55 +02:00 committed by Martin Hjelmare
parent 56a43436d7
commit 540d22d603
18 changed files with 2716 additions and 2 deletions

View file

@ -0,0 +1,19 @@
{
"config": {
"error": {
"name_exists": "Name already exists",
"wrong_location": "Location in Sweden only"
},
"step": {
"user": {
"data": {
"latitude": "Latitude",
"longitude": "Longitude",
"name": "Name"
},
"title": "Location in Sweden"
}
},
"title": "Swedish weather service (SMHI)"
}
}

View file

@ -0,0 +1,19 @@
{
"config": {
"error": {
"name_exists": "Namnet finns redan",
"wrong_location": "Endast plats i Sverige"
},
"step": {
"user": {
"data": {
"latitude": "Latitud",
"longitude": "Longitud",
"name": "Namn"
},
"title": "Plats i Sverige"
}
},
"title": "SMHI svenskt väder"
}
}

View file

@ -0,0 +1,39 @@
"""
Component for the swedish weather institute weather service.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/smhi/
"""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Config, HomeAssistant
# Have to import for config_flow to work
# even if they are not used here
from .config_flow import smhi_locations # noqa: F401
from .const import DOMAIN # noqa: F401
REQUIREMENTS = ['smhi-pkg==1.0.4']
DEFAULT_NAME = 'smhi'
async def async_setup(hass: HomeAssistant, config: Config) -> bool:
"""Set up configured smhi."""
# We allow setup only through config flow type of config
return True
async def async_setup_entry(hass: HomeAssistant,
config_entry: ConfigEntry) -> bool:
"""Set up smhi forecast as config entry."""
hass.async_create_task(hass.config_entries.async_forward_entry_setup(
config_entry, 'weather'))
return True
async def async_unload_entry(hass: HomeAssistant,
config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
await hass.config_entries.async_forward_entry_unload(
config_entry, 'weather')
return True

View file

@ -0,0 +1,124 @@
"""Config flow to configure smhi component.
First time the user creates the configuration and
a valid location is set in the hass configuration yaml
it will use that location and use it as default values.
Additional locations can be added in config form.
The input location will be checked by invoking
the API. Exception will be thrown if the location
is not supported by the API (Swedish locations only)
"""
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant import config_entries, data_entry_flow
from homeassistant.const import (CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.util import slugify
from .const import DOMAIN, HOME_LOCATION_NAME
REQUIREMENTS = ['smhi-pkg==1.0.4']
@callback
def smhi_locations(hass: HomeAssistant):
"""Return configurations of SMHI component."""
return set((slugify(entry.data[CONF_NAME])) for
entry in hass.config_entries.async_entries(DOMAIN))
@config_entries.HANDLERS.register(DOMAIN)
class SmhiFlowHandler(data_entry_flow.FlowHandler):
"""Config flow for SMHI component."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self) -> None:
"""Initialize SMHI forecast configuration flow."""
self._errors = {}
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
self._errors = {}
if user_input is not None:
is_ok = await self._check_location(
user_input[CONF_LONGITUDE],
user_input[CONF_LATITUDE]
)
if is_ok:
name = slugify(user_input[CONF_NAME])
if not self._name_in_configuration_exists(name):
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input,
)
self._errors[CONF_NAME] = 'name_exists'
else:
self._errors['base'] = 'wrong_location'
# If hass config has the location set and
# is a valid coordinate the default location
# is set as default values in the form
if not smhi_locations(self.hass):
if await self._homeassistant_location_exists():
return await self._show_config_form(
name=HOME_LOCATION_NAME,
latitude=self.hass.config.latitude,
longitude=self.hass.config.longitude
)
return await self._show_config_form()
async def _homeassistant_location_exists(self) -> bool:
"""Return true if default location is set and is valid."""
if self.hass.config.latitude != 0.0 and \
self.hass.config.longitude != 0.0:
# Return true if valid location
if await self._check_location(
self.hass.config.longitude,
self.hass.config.latitude):
return True
return False
def _name_in_configuration_exists(self, name: str) -> bool:
"""Return True if name exists in configuration."""
if name in smhi_locations(self.hass):
return True
return False
async def _show_config_form(self,
name: str = None,
latitude: str = None,
longitude: str = None):
"""Show the configuration form to edit location data."""
return self.async_show_form(
step_id='user',
data_schema=vol.Schema({
vol.Required(CONF_NAME, default=name): str,
vol.Required(CONF_LATITUDE, default=latitude): cv.latitude,
vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude
}),
errors=self._errors,
)
async def _check_location(self, longitude: str, latitude: str) -> bool:
"""Return true if location is ok."""
from smhi.smhi_lib import Smhi, SmhiForecastException
try:
session = aiohttp_client.async_get_clientsession(self.hass)
smhi_api = Smhi(longitude, latitude, session=session)
await smhi_api.async_get_forecast()
return True
except SmhiForecastException:
# The API will throw an exception if faulty location
pass
return False

View file

@ -0,0 +1,12 @@
"""Constants in smhi component."""
import logging
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
HOME_LOCATION_NAME = 'Home'
ATTR_SMHI_CLOUDINESS = 'cloudiness'
DOMAIN = 'smhi'
LOGGER = logging.getLogger('homeassistant.components.smhi')
ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}"
ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format(
HOME_LOCATION_NAME)

View file

@ -0,0 +1,19 @@
{
"config": {
"title": "Swedish weather service (SMHI)",
"step": {
"user": {
"title": "Location in Sweden",
"data": {
"name": "Name",
"latitude": "Latitude",
"longitude": "Longitude"
}
}
},
"error": {
"name_exists": "Name already exists",
"wrong_location": "Location Sweden only"
}
}
}

View file

@ -40,12 +40,21 @@ ATTR_WEATHER_WIND_SPEED = 'wind_speed'
async def async_setup(hass, config):
"""Set up the weather component."""
component = EntityComponent(_LOGGER, DOMAIN, hass)
component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass)
await component.async_setup(config)
return True
async def async_setup_entry(hass, entry):
"""Set up a config entry."""
return await hass.data[DOMAIN].async_setup_entry(entry)
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
return await hass.data[DOMAIN].async_unload_entry(entry)
class WeatherEntity(Entity):
"""ABC for weather data."""

View file

@ -0,0 +1,243 @@
"""Support for the Swedish weather institute weather service.
For more details about this platform, please refer to the documentation
https://home-assistant.io/components/weather.smhi/
"""
import asyncio
import logging
from datetime import timedelta
from typing import Dict, List
import aiohttp
import async_timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_LATITUDE, CONF_LONGITUDE,
CONF_NAME, TEMP_CELSIUS)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.util import dt, Throttle
from homeassistant.components.weather import (
WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP,
ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME,
ATTR_FORECAST_PRECIPITATION)
from homeassistant.components.smhi.const import (
ENTITY_ID_SENSOR_FORMAT, ATTR_SMHI_CLOUDINESS)
DEPENDENCIES = ['smhi']
REQUIREMENTS = ['smhi-pkg==1.0.4']
_LOGGER = logging.getLogger(__name__)
# Used to map condition from API results
CONDITION_CLASSES = {
'cloudy': [5, 6],
'fog': [7],
'hail': [],
'lightning': [21],
'lightning-rainy': [11],
'partlycloudy': [3, 4],
'pouring': [10, 20],
'rainy': [8, 9, 18, 19],
'snowy': [15, 16, 17, 25, 26, 27],
'snowy-rainy': [12, 13, 14, 22, 23, 24],
'sunny': [1, 2],
'windy': [],
'windy-variant': [],
'exceptional': [],
}
# 5 minutes between retrying connect to API again
RETRY_TIMEOUT = 5*60
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31)
async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None):
"""Old way of setting up components.
Can only be called when a user accidentally mentions smhi in the
config. In that case it will be ignored.
"""
pass
async def async_setup_entry(hass: HomeAssistant,
config_entry: ConfigEntry,
config_entries) -> bool:
"""Add a weather entity from map location."""
location = config_entry.data
name = location[CONF_NAME]
session = aiohttp_client.async_get_clientsession(hass)
entity = SmhiWeather(name, location[CONF_LATITUDE],
location[CONF_LONGITUDE],
session=session)
entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name)
config_entries([entity], True)
return True
class SmhiWeather(WeatherEntity):
"""Representation of a weather entity."""
def __init__(self, name: str, latitude: str,
longitude: str,
session: aiohttp.ClientSession = None) -> None:
"""Initialize the SMHI weather entity."""
from smhi import Smhi
self._name = name
self._latitude = latitude
self._longitude = longitude
self._forecasts = None
self._fail_count = 0
self._smhi_api = Smhi(self._longitude, self._latitude,
session=session)
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def async_update(self) -> None:
"""Refresh the forecast data from SMHI weather API."""
from smhi.smhi_lib import SmhiForecastException
def fail():
self._fail_count += 1
if self._fail_count < 3:
self.hass.helpers.event.async_call_later(
RETRY_TIMEOUT, self.retry_update())
try:
with async_timeout.timeout(10, loop=self.hass.loop):
self._forecasts = await self.get_weather_forecast()
self._fail_count = 0
except (asyncio.TimeoutError, SmhiForecastException):
_LOGGER.error("Failed to connect to SMHI API, "
"retry in 5 minutes")
fail()
async def retry_update(self):
"""Retry refresh weather forecast."""
self.async_update()
async def get_weather_forecast(self) -> []:
"""Return the current forecasts from SMHI API."""
return await self._smhi_api.async_get_forecast()
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
def temperature(self) -> int:
"""Return the temperature."""
if self._forecasts is not None:
return self._forecasts[0].temperature
return None
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
return TEMP_CELSIUS
@property
def humidity(self) -> int:
"""Return the humidity."""
if self._forecasts is not None:
return self._forecasts[0].humidity
return None
@property
def wind_speed(self) -> float:
"""Return the wind speed."""
if self._forecasts is not None:
# Convert from m/s to km/h
return round(self._forecasts[0].wind_speed*18/5)
return None
@property
def wind_bearing(self) -> int:
"""Return the wind bearing."""
if self._forecasts is not None:
return self._forecasts[0].wind_direction
return None
@property
def visibility(self) -> float:
"""Return the visibility."""
if self._forecasts is not None:
return self._forecasts[0].horizontal_visibility
return None
@property
def pressure(self) -> int:
"""Return the pressure."""
if self._forecasts is not None:
return self._forecasts[0].pressure
return None
@property
def cloudiness(self) -> int:
"""Return the cloudiness."""
if self._forecasts is not None:
return self._forecasts[0].cloudiness
return None
@property
def condition(self) -> str:
"""Return the weather condition."""
if self._forecasts is None:
return None
return next((
k for k, v in CONDITION_CLASSES.items()
if self._forecasts[0].symbol in v), None)
@property
def attribution(self) -> str:
"""Return the attribution."""
return 'Swedish weather institute (SMHI)'
@property
def forecast(self) -> List:
"""Return the forecast."""
if self._forecasts is None:
return None
data = []
for forecast in self._forecasts:
condition = next((
k for k, v in CONDITION_CLASSES.items()
if forecast.symbol in v), None)
# Only get mid day forecasts
if forecast.valid_time.hour == 12:
data.append({
ATTR_FORECAST_TIME:
dt.as_local(forecast.valid_time),
ATTR_FORECAST_TEMP:
forecast.temperature_max,
ATTR_FORECAST_TEMP_LOW:
forecast.temperature_min,
ATTR_FORECAST_PRECIPITATION:
round(forecast.mean_precipitation*24),
ATTR_FORECAST_CONDITION:
condition
})
return data
@property
def device_state_attributes(self) -> Dict:
"""Return SMHI specific attributes."""
if self.cloudiness:
return {ATTR_SMHI_CLOUDINESS: self.cloudiness}

View file

@ -146,6 +146,7 @@ FLOWS = [
'mqtt',
'nest',
'openuv',
'smhi',
'sonos',
'tradfri',
'zone',

View file

@ -1366,6 +1366,11 @@ smappy==0.2.16
# homeassistant.components.sensor.htu21d
# smbus-cffi==0.5.1
# homeassistant.components.smhi
# homeassistant.components.smhi.config_flow
# homeassistant.components.weather.smhi
smhi-pkg==1.0.4
# homeassistant.components.media_player.snapcast
snapcast==2.0.8

View file

@ -211,6 +211,11 @@ rxv==0.5.1
# homeassistant.components.sleepiq
sleepyq==0.6
# homeassistant.components.smhi
# homeassistant.components.smhi.config_flow
# homeassistant.components.weather.smhi
smhi-pkg==1.0.4
# homeassistant.components.climate.honeywell
somecomfort==0.5.2

View file

@ -96,6 +96,7 @@ TEST_REQUIREMENTS = (
'ring_doorbell',
'rxv',
'sleepyq',
'smhi-pkg',
'somecomfort',
'sqlalchemy',
'statsd',

View file

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

View file

@ -0,0 +1,11 @@
"""Common test utilities."""
from unittest.mock import Mock
class AsyncMock(Mock):
"""Implements Mock async."""
# pylint: disable=W0235
async def __call__(self, *args, **kwargs):
"""Hack for async support for Mock."""
return super(AsyncMock, self).__call__(*args, **kwargs)

View file

@ -0,0 +1,276 @@
"""Tests for SMHI config flow."""
from unittest.mock import Mock, patch
from smhi.smhi_lib import Smhi as SmhiApi, SmhiForecastException
from tests.common import mock_coro
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.components.smhi import config_flow
# pylint: disable=W0212
async def test_homeassistant_location_exists() -> None:
"""Test if homeassistant location exists it should return True."""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
with patch.object(flow, '_check_location',
return_value=mock_coro(True)):
# Test exists
hass.config.location_name = 'Home'
hass.config.latitude = 17.8419
hass.config.longitude = 59.3262
assert await flow._homeassistant_location_exists() is True
# Test not exists
hass.config.location_name = None
hass.config.latitude = 0
hass.config.longitude = 0
assert await flow._homeassistant_location_exists() is False
async def test_name_in_configuration_exists() -> None:
"""Test if home location exists in configuration."""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
# Test exists
hass.config.location_name = 'Home'
hass.config.latitude = 17.8419
hass.config.longitude = 59.3262
# Check not exists
with patch.object(config_flow, 'smhi_locations',
return_value={
'test': 'something', 'test2': 'something else'
}):
assert flow._name_in_configuration_exists('no_exist_name') is False
# Check exists
with patch.object(config_flow, 'smhi_locations',
return_value={
'test': 'something', 'name_exist': 'config'
}):
assert flow._name_in_configuration_exists('name_exist') is True
def test_smhi_locations(hass) -> None:
"""Test return empty set."""
locations = config_flow.smhi_locations(hass)
assert not locations
async def test_show_config_form() -> None:
"""Test show configuration form."""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
result = await flow._show_config_form()
assert result['type'] == 'form'
assert result['step_id'] == 'user'
async def test_show_config_form_default_values() -> None:
"""Test show configuration form."""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
result = await flow._show_config_form(
name="test", latitude='65', longitude='17')
assert result['type'] == 'form'
assert result['step_id'] == 'user'
async def test_flow_with_home_location(hass) -> None:
"""Test config flow .
Tests the flow when a default location is configured
then it should return a form with default values
"""
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
with patch.object(flow, '_check_location',
return_value=mock_coro(True)):
hass.config.location_name = 'Home'
hass.config.latitude = 17.8419
hass.config.longitude = 59.3262
result = await flow.async_step_user()
assert result['type'] == 'form'
assert result['step_id'] == 'user'
async def test_flow_show_form() -> None:
"""Test show form scenarios first time.
Test when the form should show when no configurations exists
"""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
# Test show form when home assistant config exists and
# home is already configured, then new config is allowed
with \
patch.object(flow, '_show_config_form',
return_value=mock_coro()) as config_form, \
patch.object(flow, '_homeassistant_location_exists',
return_value=mock_coro(True)), \
patch.object(config_flow, 'smhi_locations',
return_value={
'test': 'something', 'name_exist': 'config'
}):
await flow.async_step_user()
assert len(config_form.mock_calls) == 1
# Test show form when home assistant config not and
# home is not configured
with \
patch.object(flow, '_show_config_form',
return_value=mock_coro()) as config_form, \
patch.object(flow, '_homeassistant_location_exists',
return_value=mock_coro(False)), \
patch.object(config_flow, 'smhi_locations',
return_value={
'test': 'something', 'name_exist': 'config'
}):
await flow.async_step_user()
assert len(config_form.mock_calls) == 1
async def test_flow_show_form_name_exists() -> None:
"""Test show form if name already exists.
Test when the form should show when no configurations exists
"""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'}
# Test show form when home assistant config exists and
# home is already configured, then new config is allowed
with \
patch.object(flow, '_show_config_form',
return_value=mock_coro()) as config_form, \
patch.object(flow, '_name_in_configuration_exists',
return_value=True), \
patch.object(config_flow, 'smhi_locations',
return_value={
'test': 'something', 'name_exist': 'config'
}), \
patch.object(flow, '_check_location',
return_value=mock_coro(True)):
await flow.async_step_user(user_input=test_data)
assert len(config_form.mock_calls) == 1
assert len(flow._errors) == 1
async def test_flow_entry_created_from_user_input() -> None:
"""Test that create data from user input.
Test when the form should show when no configurations exists
"""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'}
# Test that entry created when user_input name not exists
with \
patch.object(flow, '_show_config_form',
return_value=mock_coro()) as config_form, \
patch.object(flow, '_name_in_configuration_exists',
return_value=False), \
patch.object(flow, '_homeassistant_location_exists',
return_value=mock_coro(False)), \
patch.object(config_flow, 'smhi_locations',
return_value={
'test': 'something', 'name_exist': 'config'
}), \
patch.object(flow, '_check_location',
return_value=mock_coro(True)):
result = await flow.async_step_user(user_input=test_data)
assert result['type'] == 'create_entry'
assert result['data'] == test_data
assert not config_form.mock_calls
async def test_flow_entry_created_user_input_faulty() -> None:
"""Test that create data from user input and are faulty.
Test when the form should show when user puts faulty location
in the config gui. Then the form should show with error
"""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'}
# Test that entry created when user_input name not exists
with \
patch.object(flow, '_check_location',
return_value=mock_coro(True)), \
patch.object(flow, '_show_config_form',
return_value=mock_coro()) as config_form, \
patch.object(flow, '_name_in_configuration_exists',
return_value=False), \
patch.object(flow, '_homeassistant_location_exists',
return_value=mock_coro(False)), \
patch.object(config_flow, 'smhi_locations',
return_value={
'test': 'something', 'name_exist': 'config'
}), \
patch.object(flow, '_check_location',
return_value=mock_coro(False)):
await flow.async_step_user(user_input=test_data)
assert len(config_form.mock_calls) == 1
assert len(flow._errors) == 1
async def test_check_location_correct() -> None:
"""Test check location when correct input."""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
with \
patch.object(config_flow.aiohttp_client, 'async_get_clientsession'),\
patch.object(SmhiApi, 'async_get_forecast',
return_value=mock_coro()):
assert await flow._check_location('58', '17') is True
async def test_check_location_faulty() -> None:
"""Test check location when faulty input."""
hass = Mock()
flow = config_flow.SmhiFlowHandler()
flow.hass = hass
with \
patch.object(config_flow.aiohttp_client,
'async_get_clientsession'), \
patch.object(SmhiApi, 'async_get_forecast',
side_effect=SmhiForecastException()):
assert await flow._check_location('58', '17') is False

View file

@ -0,0 +1,39 @@
"""Test SMHI component setup process."""
from unittest.mock import Mock
from homeassistant.components import smhi
from .common import AsyncMock
TEST_CONFIG = {
"config": {
"name": "0123456789ABCDEF",
"longitude": "62.0022",
"latitude": "17.0022"
}
}
async def test_setup_always_return_true() -> None:
"""Test async_setup always returns True."""
hass = Mock()
# Returns true with empty config
assert await smhi.async_setup(hass, {}) is True
# Returns true with a config provided
assert await smhi.async_setup(hass, TEST_CONFIG) is True
async def test_forward_async_setup_entry() -> None:
"""Test that it will forward setup entry."""
hass = Mock()
assert await smhi.async_setup_entry(hass, {}) is True
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1
async def test_forward_async_unload_entry() -> None:
"""Test that it will forward unload entry."""
hass = AsyncMock()
assert await smhi.async_unload_entry(hass, {}) is True
assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1

View file

@ -0,0 +1,292 @@
"""Test for the smhi weather entity."""
import asyncio
import logging
from datetime import datetime, timezone
from unittest.mock import Mock, patch
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME,
ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE,
ATTR_FORECAST_TEMP_LOW, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_ATTRIBUTION,
ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED,
ATTR_FORECAST_PRECIPITATION, smhi as weather_smhi,
DOMAIN as WEATHER_DOMAIN)
from homeassistant.const import TEMP_CELSIUS
from homeassistant.core import HomeAssistant
from tests.common import load_fixture, MockConfigEntry
from homeassistant.components.smhi.const import ATTR_SMHI_CLOUDINESS
_LOGGER = logging.getLogger(__name__)
TEST_CONFIG = {
"name": "test",
"longitude": "17.84197",
"latitude": "59.32624"
}
async def test_setup_hass(hass: HomeAssistant, aioclient_mock) -> None:
"""Test for successfully setting up the smhi platform.
This test are deeper integrated with the core. Since only
config_flow is used the component are setup with
"async_forward_entry_setup". The actual result are tested
with the entity state rather than "per function" unity tests
"""
from smhi.smhi_lib import APIURL_TEMPLATE
uri = APIURL_TEMPLATE.format(
TEST_CONFIG['longitude'], TEST_CONFIG['latitude'])
api_response = load_fixture('smhi.json')
aioclient_mock.get(uri, text=api_response)
entry = MockConfigEntry(domain='smhi', data=TEST_CONFIG)
await hass.config_entries.async_forward_entry_setup(entry, WEATHER_DOMAIN)
assert aioclient_mock.call_count == 1
# Testing the actual entity state for
# deeper testing than normal unity test
state = hass.states.get('weather.smhi_test')
assert state.state == 'sunny'
assert state.attributes[ATTR_SMHI_CLOUDINESS] == 50
assert state.attributes[ATTR_WEATHER_ATTRIBUTION].find('SMHI') >= 0
assert state.attributes[ATTR_WEATHER_HUMIDITY] == 55
assert state.attributes[ATTR_WEATHER_PRESSURE] == 1024
assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 17
assert state.attributes[ATTR_WEATHER_VISIBILITY] == 50
assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 7
assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 134
_LOGGER.error(state.attributes)
assert len(state.attributes['forecast']) == 1
forecast = state.attributes['forecast'][0]
assert forecast[ATTR_FORECAST_TIME] == datetime(2018, 9, 2, 12, 0,
tzinfo=timezone.utc)
assert forecast[ATTR_FORECAST_TEMP] == 20
assert forecast[ATTR_FORECAST_TEMP_LOW] == 6
assert forecast[ATTR_FORECAST_PRECIPITATION] == 0
assert forecast[ATTR_FORECAST_CONDITION] == 'partlycloudy'
async def test_setup_plattform(hass):
"""Test that setup plattform does nothing."""
assert await weather_smhi.async_setup_platform(hass, None, None) is None
def test_properties_no_data(hass: HomeAssistant) -> None:
"""Test properties when no API data available."""
weather = weather_smhi.SmhiWeather('name', '10', '10')
weather.hass = hass
assert weather.name == 'name'
assert weather.should_poll is True
assert weather.temperature is None
assert weather.humidity is None
assert weather.wind_speed is None
assert weather.wind_bearing is None
assert weather.visibility is None
assert weather.pressure is None
assert weather.cloudiness is None
assert weather.condition is None
assert weather.forecast is None
assert weather.temperature_unit == TEMP_CELSIUS
# pylint: disable=W0212
def test_properties_unknown_symbol() -> None:
"""Test behaviour when unknown symbol from API."""
hass = Mock()
data = Mock()
data.temperature = 5
data.mean_precipitation = 1
data.humidity = 5
data.wind_speed = 10
data.wind_direction = 180
data.horizontal_visibility = 6
data.pressure = 1008
data.cloudiness = 52
data.symbol = 100 # Faulty symbol
data.valid_time = datetime(2018, 1, 1, 0, 1, 2)
data2 = Mock()
data2.temperature = 5
data2.mean_precipitation = 1
data2.humidity = 5
data2.wind_speed = 10
data2.wind_direction = 180
data2.horizontal_visibility = 6
data2.pressure = 1008
data2.cloudiness = 52
data2.symbol = 100 # Faulty symbol
data2.valid_time = datetime(2018, 1, 1, 12, 1, 2)
data3 = Mock()
data3.temperature = 5
data3.mean_precipitation = 1
data3.humidity = 5
data3.wind_speed = 10
data3.wind_direction = 180
data3.horizontal_visibility = 6
data3.pressure = 1008
data3.cloudiness = 52
data3.symbol = 100 # Faulty symbol
data3.valid_time = datetime(2018, 1, 2, 12, 1, 2)
testdata = [
data,
data2,
data3
]
weather = weather_smhi.SmhiWeather('name', '10', '10')
weather.hass = hass
weather._forecasts = testdata
assert weather.condition is None
forecast = weather.forecast[0]
assert forecast[ATTR_FORECAST_CONDITION] is None
# pylint: disable=W0212
async def test_refresh_weather_forecast_exceeds_retries(hass) -> None:
"""Test the refresh weather forecast function."""
from smhi.smhi_lib import SmhiForecastException
with \
patch.object(hass.helpers.event, 'async_call_later') as call_later, \
patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast',
side_effect=SmhiForecastException()):
weather = weather_smhi.SmhiWeather(
'name', '17.0022', '62.0022')
weather.hass = hass
weather._fail_count = 2
await weather.async_update()
assert weather._forecasts is None
assert not call_later.mock_calls
async def test_refresh_weather_forecast_timeout(hass) -> None:
"""Test timeout exception."""
weather = weather_smhi.SmhiWeather(
'name', '17.0022', '62.0022')
weather.hass = hass
with \
patch.object(hass.helpers.event, 'async_call_later') as call_later, \
patch.object(weather_smhi.SmhiWeather, 'retry_update'), \
patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast',
side_effect=asyncio.TimeoutError):
await weather.async_update()
assert len(call_later.mock_calls) == 1
# Assert we are going to wait RETRY_TIMEOUT seconds
assert call_later.mock_calls[0][1][0] == weather_smhi.RETRY_TIMEOUT
async def test_refresh_weather_forecast_exception() -> None:
"""Test any exception."""
from smhi.smhi_lib import SmhiForecastException
hass = Mock()
weather = weather_smhi.SmhiWeather(
'name', '17.0022', '62.0022')
weather.hass = hass
with \
patch.object(hass.helpers.event, 'async_call_later') as call_later, \
patch.object(weather_smhi, 'async_timeout'), \
patch.object(weather_smhi.SmhiWeather, 'retry_update'), \
patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast',
side_effect=SmhiForecastException()):
hass.async_add_job = Mock()
call_later = hass.helpers.event.async_call_later
await weather.async_update()
assert len(call_later.mock_calls) == 1
# Assert we are going to wait RETRY_TIMEOUT seconds
assert call_later.mock_calls[0][1][0] == weather_smhi.RETRY_TIMEOUT
async def test_retry_update():
"""Test retry function of refresh forecast."""
hass = Mock()
weather = weather_smhi.SmhiWeather(
'name', '17.0022', '62.0022')
weather.hass = hass
with patch.object(weather_smhi.SmhiWeather,
'async_update') as update:
await weather.retry_update()
assert len(update.mock_calls) == 1
def test_condition_class():
"""Test condition class."""
def get_condition(index: int) -> str:
"""Return condition given index."""
return [k for k, v in weather_smhi.CONDITION_CLASSES.items()
if index in v][0]
# SMHI definitions as follows, see
# http://opendata.smhi.se/apidocs/metfcst/parameters.html
# 1. Clear sky
assert get_condition(1) == 'sunny'
# 2. Nearly clear sky
assert get_condition(2) == 'sunny'
# 3. Variable cloudiness
assert get_condition(3) == 'partlycloudy'
# 4. Halfclear sky
assert get_condition(4) == 'partlycloudy'
# 5. Cloudy sky
assert get_condition(5) == 'cloudy'
# 6. Overcast
assert get_condition(6) == 'cloudy'
# 7. Fog
assert get_condition(7) == 'fog'
# 8. Light rain showers
assert get_condition(8) == 'rainy'
# 9. Moderate rain showers
assert get_condition(9) == 'rainy'
# 18. Light rain
assert get_condition(18) == 'rainy'
# 19. Moderate rain
assert get_condition(19) == 'rainy'
# 10. Heavy rain showers
assert get_condition(10) == 'pouring'
# 20. Heavy rain
assert get_condition(20) == 'pouring'
# 21. Thunder
assert get_condition(21) == 'lightning'
# 11. Thunderstorm
assert get_condition(11) == 'lightning-rainy'
# 15. Light snow showers
assert get_condition(15) == 'snowy'
# 16. Moderate snow showers
assert get_condition(16) == 'snowy'
# 17. Heavy snow showers
assert get_condition(17) == 'snowy'
# 25. Light snowfall
assert get_condition(25) == 'snowy'
# 26. Moderate snowfall
assert get_condition(26) == 'snowy'
# 27. Heavy snowfall
assert get_condition(27) == 'snowy'
# 12. Light sleet showers
assert get_condition(12) == 'snowy-rainy'
# 13. Moderate sleet showers
assert get_condition(13) == 'snowy-rainy'
# 14. Heavy sleet showers
assert get_condition(14) == 'snowy-rainy'
# 22. Light sleet
assert get_condition(22) == 'snowy-rainy'
# 23. Moderate sleet
assert get_condition(23) == 'snowy-rainy'
# 24. Heavy sleet
assert get_condition(24) == 'snowy-rainy'

1599
tests/fixtures/smhi.json vendored Normal file

File diff suppressed because it is too large Load diff