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:
parent
56a43436d7
commit
540d22d603
18 changed files with 2716 additions and 2 deletions
19
homeassistant/components/smhi/.translations/en.json
Normal file
19
homeassistant/components/smhi/.translations/en.json
Normal 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)"
|
||||
}
|
||||
}
|
19
homeassistant/components/smhi/.translations/sv.json
Normal file
19
homeassistant/components/smhi/.translations/sv.json
Normal 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"
|
||||
}
|
||||
}
|
39
homeassistant/components/smhi/__init__.py
Normal file
39
homeassistant/components/smhi/__init__.py
Normal 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
|
124
homeassistant/components/smhi/config_flow.py
Normal file
124
homeassistant/components/smhi/config_flow.py
Normal 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
|
12
homeassistant/components/smhi/const.py
Normal file
12
homeassistant/components/smhi/const.py
Normal 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)
|
19
homeassistant/components/smhi/strings.json
Normal file
19
homeassistant/components/smhi/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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."""
|
||||
|
||||
|
|
243
homeassistant/components/weather/smhi.py
Normal file
243
homeassistant/components/weather/smhi.py
Normal 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}
|
|
@ -146,6 +146,7 @@ FLOWS = [
|
|||
'mqtt',
|
||||
'nest',
|
||||
'openuv',
|
||||
'smhi',
|
||||
'sonos',
|
||||
'tradfri',
|
||||
'zone',
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -96,6 +96,7 @@ TEST_REQUIREMENTS = (
|
|||
'ring_doorbell',
|
||||
'rxv',
|
||||
'sleepyq',
|
||||
'smhi-pkg',
|
||||
'somecomfort',
|
||||
'sqlalchemy',
|
||||
'statsd',
|
||||
|
|
1
tests/components/smhi/__init__.py
Normal file
1
tests/components/smhi/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the SMHI component."""
|
11
tests/components/smhi/common.py
Normal file
11
tests/components/smhi/common.py
Normal 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)
|
276
tests/components/smhi/test_config_flow.py
Normal file
276
tests/components/smhi/test_config_flow.py
Normal 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
|
39
tests/components/smhi/test_init.py
Normal file
39
tests/components/smhi/test_init.py
Normal 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
|
292
tests/components/weather/test_smhi.py
Normal file
292
tests/components/weather/test_smhi.py
Normal 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
1599
tests/fixtures/smhi.json
vendored
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue