From 540d22d603605b0ee39c932bff42f2b5212f2e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomas=20Hellstr=C3=B6m?= Date: Mon, 8 Oct 2018 23:54:55 +0200 Subject: [PATCH] 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. --- .../components/smhi/.translations/en.json | 19 + .../components/smhi/.translations/sv.json | 19 + homeassistant/components/smhi/__init__.py | 39 + homeassistant/components/smhi/config_flow.py | 124 ++ homeassistant/components/smhi/const.py | 12 + homeassistant/components/smhi/strings.json | 19 + homeassistant/components/weather/__init__.py | 13 +- homeassistant/components/weather/smhi.py | 243 +++ homeassistant/config_entries.py | 1 + requirements_all.txt | 5 + requirements_test_all.txt | 5 + script/gen_requirements_all.py | 1 + tests/components/smhi/__init__.py | 1 + tests/components/smhi/common.py | 11 + tests/components/smhi/test_config_flow.py | 276 +++ tests/components/smhi/test_init.py | 39 + tests/components/weather/test_smhi.py | 292 +++ tests/fixtures/smhi.json | 1599 +++++++++++++++++ 18 files changed, 2716 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/smhi/.translations/en.json create mode 100644 homeassistant/components/smhi/.translations/sv.json create mode 100644 homeassistant/components/smhi/__init__.py create mode 100644 homeassistant/components/smhi/config_flow.py create mode 100644 homeassistant/components/smhi/const.py create mode 100644 homeassistant/components/smhi/strings.json create mode 100644 homeassistant/components/weather/smhi.py create mode 100644 tests/components/smhi/__init__.py create mode 100644 tests/components/smhi/common.py create mode 100644 tests/components/smhi/test_config_flow.py create mode 100644 tests/components/smhi/test_init.py create mode 100644 tests/components/weather/test_smhi.py create mode 100644 tests/fixtures/smhi.json diff --git a/homeassistant/components/smhi/.translations/en.json b/homeassistant/components/smhi/.translations/en.json new file mode 100644 index 00000000000..1a995a64a32 --- /dev/null +++ b/homeassistant/components/smhi/.translations/en.json @@ -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)" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/sv.json b/homeassistant/components/smhi/.translations/sv.json new file mode 100644 index 00000000000..30d647ac2c4 --- /dev/null +++ b/homeassistant/components/smhi/.translations/sv.json @@ -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" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py new file mode 100644 index 00000000000..d0e4b6ef487 --- /dev/null +++ b/homeassistant/components/smhi/__init__.py @@ -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 diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py new file mode 100644 index 00000000000..3c9875ab797 --- /dev/null +++ b/homeassistant/components/smhi/config_flow.py @@ -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 diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py new file mode 100644 index 00000000000..49e0f295873 --- /dev/null +++ b/homeassistant/components/smhi/const.py @@ -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) diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json new file mode 100644 index 00000000000..dbf1172b7d6 --- /dev/null +++ b/homeassistant/components/smhi/strings.json @@ -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" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index f9a8f1fbbe4..725c7f609a7 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -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.""" diff --git a/homeassistant/components/weather/smhi.py b/homeassistant/components/weather/smhi.py new file mode 100644 index 00000000000..c24d3f8f091 --- /dev/null +++ b/homeassistant/components/weather/smhi.py @@ -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} diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a5c44c30ce7..56d4d24eea2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -146,6 +146,7 @@ FLOWS = [ 'mqtt', 'nest', 'openuv', + 'smhi', 'sonos', 'tradfri', 'zone', diff --git a/requirements_all.txt b/requirements_all.txt index 08a3ce1149a..21b50dc4b95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4e995d580c..754a8947a70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 9c0323bf5ca..9c695054ffc 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -96,6 +96,7 @@ TEST_REQUIREMENTS = ( 'ring_doorbell', 'rxv', 'sleepyq', + 'smhi-pkg', 'somecomfort', 'sqlalchemy', 'statsd', diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py new file mode 100644 index 00000000000..100b1f1bbb1 --- /dev/null +++ b/tests/components/smhi/__init__.py @@ -0,0 +1 @@ +"""Tests for the SMHI component.""" diff --git a/tests/components/smhi/common.py b/tests/components/smhi/common.py new file mode 100644 index 00000000000..ecf904ac9c9 --- /dev/null +++ b/tests/components/smhi/common.py @@ -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) diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py new file mode 100644 index 00000000000..b4e543231d9 --- /dev/null +++ b/tests/components/smhi/test_config_flow.py @@ -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 diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py new file mode 100644 index 00000000000..2b0dbaabafd --- /dev/null +++ b/tests/components/smhi/test_init.py @@ -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 diff --git a/tests/components/weather/test_smhi.py b/tests/components/weather/test_smhi.py new file mode 100644 index 00000000000..11a5028842b --- /dev/null +++ b/tests/components/weather/test_smhi.py @@ -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' diff --git a/tests/fixtures/smhi.json b/tests/fixtures/smhi.json new file mode 100644 index 00000000000..f66cc546018 --- /dev/null +++ b/tests/fixtures/smhi.json @@ -0,0 +1,1599 @@ +{ + "approvedTime": "2018-09-01T14:06:18Z", + "referenceTime": "2018-09-01T14:00:00Z", + "geometry": { + "type": "Point", + "coordinates": [ + [ + 16.024394, + 63.341937 + ] + ] + }, + "timeSeries": [ + { + "validTime": "2018-09-01T15:00:00Z", + "parameters": [ + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 2 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 1 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 4 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1024.6 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 17 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 134 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.9 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 55 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 33 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 4.7 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-02T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1026 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 6 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 12 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 214 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 0.7 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 87 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.5 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-02T11:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1026.6 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 19.8 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 201 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.8 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 43 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 6 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 6 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 5.2 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 3 + ] + } + ] + }, + { + "validTime": "2018-09-02T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1026.5 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 20.6 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 203 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.7 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 43 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 9 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 6 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 5.1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 3 + ] + } + ] + }, + { + "validTime": "2018-09-02T23:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1026 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 9.3 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 19.4 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 95 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 0.5 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 75 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 1 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-03T00:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1025.9 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 8.5 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 104 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 0.5 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 73 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 1 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-03T01:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1025.6 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 8 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 116 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 0.3 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 74 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 1 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 1 + ] + } + ] + }, + { + "validTime": "2018-09-04T12:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1020.5 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 19.2 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 353 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 1.4 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 60 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 7 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 3 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 5 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 4 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 4.7 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + -9 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 0 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 4 + ] + } + ] + }, + { + "validTime": "2018-09-04T18:00:00Z", + "parameters": [ + { + "name": "msl", + "levelType": "hmsl", + "level": 0, + "unit": "hPa", + "values": [ + 1021.5 + ] + }, + { + "name": "t", + "levelType": "hl", + "level": 2, + "unit": "Cel", + "values": [ + 14.3 + ] + }, + { + "name": "vis", + "levelType": "hl", + "level": 2, + "unit": "km", + "values": [ + 50 + ] + }, + { + "name": "wd", + "levelType": "hl", + "level": 10, + "unit": "degree", + "values": [ + 333 + ] + }, + { + "name": "ws", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 2.3 + ] + }, + { + "name": "r", + "levelType": "hl", + "level": 2, + "unit": "percent", + "values": [ + 81 + ] + }, + { + "name": "tstm", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "tcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 4 + ] + }, + { + "name": "lcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 1 + ] + }, + { + "name": "mcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 4 + ] + }, + { + "name": "hcc_mean", + "levelType": "hl", + "level": 0, + "unit": "octas", + "values": [ + 0 + ] + }, + { + "name": "gust", + "levelType": "hl", + "level": 10, + "unit": "m/s", + "values": [ + 4.5 + ] + }, + { + "name": "pmin", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmax", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0.2 + ] + }, + { + "name": "spp", + "levelType": "hl", + "level": 0, + "unit": "percent", + "values": [ + 0 + ] + }, + { + "name": "pcat", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 4 + ] + }, + { + "name": "pmean", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "pmedian", + "levelType": "hl", + "level": 0, + "unit": "kg/m2/h", + "values": [ + 0 + ] + }, + { + "name": "Wsymb2", + "levelType": "hl", + "level": 0, + "unit": "category", + "values": [ + 3 + ] + } + ] + } + ] +} \ No newline at end of file