Add new Meteoclimatic integration (#36906)

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Franck Nijhof <git@frenck.dev>
This commit is contained in:
Adrián Moreno
2021-05-25 13:11:48 +02:00
committed by GitHub
parent 3573249720
commit fe34f42aa5
15 changed files with 509 additions and 0 deletions

View File

@ -611,6 +611,9 @@ omit =
homeassistant/components/meteo_france/sensor.py
homeassistant/components/meteo_france/weather.py
homeassistant/components/meteoalarm/*
homeassistant/components/meteoclimatic/__init__.py
homeassistant/components/meteoclimatic/const.py
homeassistant/components/meteoclimatic/weather.py
homeassistant/components/metoffice/sensor.py
homeassistant/components/metoffice/weather.py
homeassistant/components/microsoft/tts.py

View File

@ -290,6 +290,7 @@ homeassistant/components/met/* @danielhiversen @thimic
homeassistant/components/met_eireann/* @DylanGore
homeassistant/components/meteo_france/* @hacf-fr @oncleben31 @Quentame
homeassistant/components/meteoalarm/* @rolfberkenbosch
homeassistant/components/meteoclimatic/* @adrianmo
homeassistant/components/metoffice/* @MrHarcombe
homeassistant/components/miflora/* @danielhiversen @basnijholt
homeassistant/components/mikrotik/* @engrbm87

View File

@ -0,0 +1,52 @@
"""Support for Meteoclimatic weather data."""
import logging
from meteoclimatic import MeteoclimaticClient
from meteoclimatic.exceptions import MeteoclimaticError
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_STATION_CODE, DOMAIN, PLATFORMS, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Set up a Meteoclimatic entry."""
station_code = entry.data[CONF_STATION_CODE]
meteoclimatic_client = MeteoclimaticClient()
async def async_update_data():
"""Obtain the latest data from Meteoclimatic."""
try:
data = await hass.async_add_executor_job(
meteoclimatic_client.weather_at_station, station_code
)
return data.__dict__
except MeteoclimaticError as err:
raise UpdateFailed(f"Error while retrieving data: {err}") from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
name=f"Meteoclimatic Coordinator for {station_code}",
update_method=async_update_data,
update_interval=SCAN_INTERVAL,
)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
return unload_ok

View File

@ -0,0 +1,64 @@
"""Config flow to configure the Meteoclimatic integration."""
import logging
from meteoclimatic import MeteoclimaticClient
from meteoclimatic.exceptions import MeteoclimaticError, StationNotFound
import voluptuous as vol
from homeassistant import config_entries
from .const import CONF_STATION_CODE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class MeteoclimaticFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Meteoclimatic config flow."""
VERSION = 1
def _show_setup_form(self, user_input=None, errors=None):
"""Show the setup form to the user."""
if user_input is None:
user_input = {}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_STATION_CODE, default=user_input.get(CONF_STATION_CODE, "")
): str
}
),
errors=errors or {},
)
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
errors = {}
if user_input is None:
return self._show_setup_form(user_input, errors)
station_code = user_input[CONF_STATION_CODE]
client = MeteoclimaticClient()
try:
weather = await self.hass.async_add_executor_job(
client.weather_at_station, station_code
)
except StationNotFound as exp:
_LOGGER.error("Station not found: %s", exp)
errors["base"] = "not_found"
return self._show_setup_form(user_input, errors)
except MeteoclimaticError as exp:
_LOGGER.error("Error when obtaining Meteoclimatic weather: %s", exp)
return self.async_abort(reason="unknown")
# Check if already configured
await self.async_set_unique_id(station_code, raise_on_progress=False)
return self.async_create_entry(
title=weather.station.name, data={CONF_STATION_CODE: station_code}
)

View File

@ -0,0 +1,134 @@
"""Meteoclimatic component constants."""
from datetime import timedelta
from meteoclimatic import Condition
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_EXCEPTIONAL,
ATTR_CONDITION_FOG,
ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
ATTR_CONDITION_RAINY,
ATTR_CONDITION_SNOWY,
ATTR_CONDITION_SNOWY_RAINY,
ATTR_CONDITION_SUNNY,
ATTR_CONDITION_WINDY,
ATTR_CONDITION_WINDY_VARIANT,
)
from homeassistant.const import (
DEGREE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
LENGTH_MILLIMETERS,
PERCENTAGE,
PRESSURE_HPA,
SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS,
)
DOMAIN = "meteoclimatic"
PLATFORMS = ["weather"]
ATTRIBUTION = "Data provided by Meteoclimatic"
SCAN_INTERVAL = timedelta(minutes=10)
CONF_STATION_CODE = "station_code"
DEFAULT_WEATHER_CARD = True
SENSOR_TYPE_NAME = "name"
SENSOR_TYPE_UNIT = "unit"
SENSOR_TYPE_ICON = "icon"
SENSOR_TYPE_CLASS = "device_class"
SENSOR_TYPES = {
"temp_current": {
SENSOR_TYPE_NAME: "Temperature",
SENSOR_TYPE_UNIT: TEMP_CELSIUS,
SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE,
},
"temp_max": {
SENSOR_TYPE_NAME: "Max Temp.",
SENSOR_TYPE_UNIT: TEMP_CELSIUS,
SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE,
},
"temp_min": {
SENSOR_TYPE_NAME: "Min Temp.",
SENSOR_TYPE_UNIT: TEMP_CELSIUS,
SENSOR_TYPE_CLASS: DEVICE_CLASS_TEMPERATURE,
},
"humidity_current": {
SENSOR_TYPE_NAME: "Humidity",
SENSOR_TYPE_UNIT: PERCENTAGE,
SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY,
},
"humidity_max": {
SENSOR_TYPE_NAME: "Max Humidity",
SENSOR_TYPE_UNIT: PERCENTAGE,
SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY,
},
"humidity_min": {
SENSOR_TYPE_NAME: "Min Humidity",
SENSOR_TYPE_UNIT: PERCENTAGE,
SENSOR_TYPE_CLASS: DEVICE_CLASS_HUMIDITY,
},
"pressure_current": {
SENSOR_TYPE_NAME: "Pressure",
SENSOR_TYPE_UNIT: PRESSURE_HPA,
SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE,
},
"pressure_max": {
SENSOR_TYPE_NAME: "Max Pressure",
SENSOR_TYPE_UNIT: PRESSURE_HPA,
SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE,
},
"pressure_min": {
SENSOR_TYPE_NAME: "Min Pressure",
SENSOR_TYPE_UNIT: PRESSURE_HPA,
SENSOR_TYPE_CLASS: DEVICE_CLASS_PRESSURE,
},
"wind_current": {
SENSOR_TYPE_NAME: "Wind Speed",
SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR,
SENSOR_TYPE_ICON: "mdi:weather-windy",
},
"wind_max": {
SENSOR_TYPE_NAME: "Max Wind Speed",
SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR,
SENSOR_TYPE_ICON: "mdi:weather-windy",
},
"wind_bearing": {
SENSOR_TYPE_NAME: "Wind Bearing",
SENSOR_TYPE_UNIT: DEGREE,
SENSOR_TYPE_ICON: "mdi:weather-windy",
},
"rain": {
SENSOR_TYPE_NAME: "Rain",
SENSOR_TYPE_UNIT: LENGTH_MILLIMETERS,
SENSOR_TYPE_ICON: "mdi:weather-rainy",
},
}
CONDITION_CLASSES = {
ATTR_CONDITION_CLEAR_NIGHT: [Condition.moon, Condition.hazemoon],
ATTR_CONDITION_CLOUDY: [Condition.mooncloud],
ATTR_CONDITION_EXCEPTIONAL: [],
ATTR_CONDITION_FOG: [Condition.fog, Condition.mist],
ATTR_CONDITION_HAIL: [],
ATTR_CONDITION_LIGHTNING: [Condition.storm],
ATTR_CONDITION_LIGHTNING_RAINY: [],
ATTR_CONDITION_PARTLYCLOUDY: [Condition.suncloud, Condition.hazesun],
ATTR_CONDITION_POURING: [],
ATTR_CONDITION_RAINY: [Condition.rain],
ATTR_CONDITION_SNOWY: [],
ATTR_CONDITION_SNOWY_RAINY: [],
ATTR_CONDITION_SUNNY: [Condition.sun],
ATTR_CONDITION_WINDY: [],
ATTR_CONDITION_WINDY_VARIANT: [],
}

View File

@ -0,0 +1,13 @@
{
"domain": "meteoclimatic",
"name": "Meteoclimatic",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/meteoclimatic",
"requirements": [
"pymeteoclimatic==0.0.6"
],
"codeowners": [
"@adrianmo"
],
"iot_class": "cloud_polling"
}

View File

@ -0,0 +1,20 @@
{
"config": {
"step": {
"user": {
"title": "Meteoclimatic",
"description": "Enter the Meteoclimatic station code (e.g., ESCAT4300000043206B)",
"data": {
"code": "Station code"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"not_found": "[%key:common::config_flow::abort::no_devices_found%]"
}
}
}

View File

@ -0,0 +1,20 @@
{
"config": {
"abort": {
"already_configured": "Station already configured",
"unknown": "Unknown error: please try again later"
},
"error": {
"not_found": "The station code did not return any data. Check that the code belongs to a station and it has the right format (e.g., ESCAT4300000043206B)"
},
"step": {
"user": {
"data": {
"code": "Station code"
},
"description": "Enter the Meteoclimatic station code (e.g., ESCAT4300000043206B)",
"title": "Meteoclimatic"
}
}
}
}

View File

@ -0,0 +1,93 @@
"""Support for Meteoclimatic weather service."""
from meteoclimatic import Condition
from homeassistant.components.weather import WeatherEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import TEMP_CELSIUS
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .const import ATTRIBUTION, CONDITION_CLASSES, DOMAIN
def format_condition(condition):
"""Return condition from dict CONDITION_CLASSES."""
for key, value in CONDITION_CLASSES.items():
if condition in value:
return key
if isinstance(condition, Condition):
return condition.value
return condition
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the Meteoclimatic weather platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([MeteoclimaticWeather(coordinator)], False)
class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity):
"""Representation of a weather condition."""
def __init__(self, coordinator: DataUpdateCoordinator) -> None:
"""Initialise the weather platform."""
super().__init__(coordinator)
self._unique_id = self.coordinator.data["station"].code
self._name = self.coordinator.data["station"].name
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def unique_id(self):
"""Return the unique id of the sensor."""
return self._unique_id
@property
def condition(self):
"""Return the current condition."""
return format_condition(self.coordinator.data["weather"].condition)
@property
def temperature(self):
"""Return the temperature."""
return self.coordinator.data["weather"].temp_current
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return TEMP_CELSIUS
@property
def humidity(self):
"""Return the humidity."""
return self.coordinator.data["weather"].humidity_current
@property
def pressure(self):
"""Return the pressure."""
return self.coordinator.data["weather"].pressure_current
@property
def wind_speed(self):
"""Return the wind speed."""
return self.coordinator.data["weather"].wind_current
@property
def wind_bearing(self):
"""Return the wind bearing."""
return self.coordinator.data["weather"].wind_bearing
@property
def attribution(self):
"""Return the attribution."""
return ATTRIBUTION

View File

@ -150,6 +150,7 @@ FLOWS = [
"met",
"met_eireann",
"meteo_france",
"meteoclimatic",
"metoffice",
"mikrotik",
"mill",

View File

@ -1559,6 +1559,9 @@ pymediaroom==0.6.4.1
# homeassistant.components.melcloud
pymelcloud==2.5.2
# homeassistant.components.meteoclimatic
pymeteoclimatic==0.0.6
# homeassistant.components.somfy
pymfy==0.9.3

View File

@ -870,6 +870,9 @@ pymazda==0.1.5
# homeassistant.components.melcloud
pymelcloud==2.5.2
# homeassistant.components.meteoclimatic
pymeteoclimatic==0.0.6
# homeassistant.components.somfy
pymfy==0.9.3

View File

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

View File

@ -0,0 +1,13 @@
"""Meteoclimatic generic test utils."""
from unittest.mock import patch
import pytest
@pytest.fixture(autouse=True)
def patch_requests():
"""Stub out services that makes requests."""
patch_client = patch("homeassistant.components.meteoclimatic.MeteoclimaticClient")
with patch_client:
yield

View File

@ -0,0 +1,88 @@
"""Tests for the Meteoclimatic config flow."""
from unittest.mock import patch
from meteoclimatic.exceptions import MeteoclimaticError, StationNotFound
import pytest
from homeassistant import data_entry_flow
from homeassistant.components.meteoclimatic.const import CONF_STATION_CODE, DOMAIN
from homeassistant.config_entries import SOURCE_USER
TEST_STATION_CODE = "ESCAT4300000043206B"
TEST_STATION_NAME = "Reus (Tarragona)"
@pytest.fixture(name="client")
def mock_controller_client():
"""Mock a successful client."""
with patch(
"homeassistant.components.meteoclimatic.config_flow.MeteoclimaticClient",
update=False,
) as service_mock:
service_mock.return_value.get_data.return_value = {
"station_code": TEST_STATION_CODE
}
weather = service_mock.return_value.weather_at_station.return_value
weather.station.name = TEST_STATION_NAME
yield service_mock
@pytest.fixture(autouse=True)
def mock_setup():
"""Prevent setup."""
with patch(
"homeassistant.components.meteoclimatic.async_setup_entry",
return_value=True,
):
yield
async def test_user(hass, client):
"""Test user config."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
# test with all provided
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_STATION_CODE: TEST_STATION_CODE},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["result"].unique_id == TEST_STATION_CODE
assert result["title"] == TEST_STATION_NAME
assert result["data"][CONF_STATION_CODE] == TEST_STATION_CODE
async def test_not_found(hass):
"""Test when we have the station code is not found."""
with patch(
"homeassistant.components.meteoclimatic.config_flow.MeteoclimaticClient.weather_at_station",
side_effect=StationNotFound(TEST_STATION_CODE),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_STATION_CODE: TEST_STATION_CODE},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "not_found"
async def test_unknown_error(hass):
"""Test when we have an unknown error fetching station data."""
with patch(
"homeassistant.components.meteoclimatic.config_flow.MeteoclimaticClient.weather_at_station",
side_effect=MeteoclimaticError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_STATION_CODE: TEST_STATION_CODE},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "unknown"