diff --git a/.coveragerc b/.coveragerc index 5fc54f1dbea..3399a10a8b0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/CODEOWNERS b/CODEOWNERS index 3d7a53749cb..faa623456f1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/meteoclimatic/__init__.py b/homeassistant/components/meteoclimatic/__init__.py new file mode 100644 index 00000000000..79e63e9b64d --- /dev/null +++ b/homeassistant/components/meteoclimatic/__init__.py @@ -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 diff --git a/homeassistant/components/meteoclimatic/config_flow.py b/homeassistant/components/meteoclimatic/config_flow.py new file mode 100644 index 00000000000..49be3889ead --- /dev/null +++ b/homeassistant/components/meteoclimatic/config_flow.py @@ -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} + ) diff --git a/homeassistant/components/meteoclimatic/const.py b/homeassistant/components/meteoclimatic/const.py new file mode 100644 index 00000000000..eb3823a9b42 --- /dev/null +++ b/homeassistant/components/meteoclimatic/const.py @@ -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: [], +} diff --git a/homeassistant/components/meteoclimatic/manifest.json b/homeassistant/components/meteoclimatic/manifest.json new file mode 100644 index 00000000000..71174f216a4 --- /dev/null +++ b/homeassistant/components/meteoclimatic/manifest.json @@ -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" +} diff --git a/homeassistant/components/meteoclimatic/strings.json b/homeassistant/components/meteoclimatic/strings.json new file mode 100644 index 00000000000..2353c22c7cc --- /dev/null +++ b/homeassistant/components/meteoclimatic/strings.json @@ -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%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/translations/en.json b/homeassistant/components/meteoclimatic/translations/en.json new file mode 100644 index 00000000000..4868d4e4656 --- /dev/null +++ b/homeassistant/components/meteoclimatic/translations/en.json @@ -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" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py new file mode 100644 index 00000000000..7059e935b2e --- /dev/null +++ b/homeassistant/components/meteoclimatic/weather.py @@ -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 diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d927b244ede..79245491a7e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -150,6 +150,7 @@ FLOWS = [ "met", "met_eireann", "meteo_france", + "meteoclimatic", "metoffice", "mikrotik", "mill", diff --git a/requirements_all.txt b/requirements_all.txt index 0d41f2be892..e02fdf5d5ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b5de01177a..cb53e0c013d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/meteoclimatic/__init__.py b/tests/components/meteoclimatic/__init__.py new file mode 100644 index 00000000000..29ba2ef18c3 --- /dev/null +++ b/tests/components/meteoclimatic/__init__.py @@ -0,0 +1 @@ +"""Tests for the Meteoclimatic component.""" diff --git a/tests/components/meteoclimatic/conftest.py b/tests/components/meteoclimatic/conftest.py new file mode 100644 index 00000000000..964f67d6473 --- /dev/null +++ b/tests/components/meteoclimatic/conftest.py @@ -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 diff --git a/tests/components/meteoclimatic/test_config_flow.py b/tests/components/meteoclimatic/test_config_flow.py new file mode 100644 index 00000000000..e5daaea1978 --- /dev/null +++ b/tests/components/meteoclimatic/test_config_flow.py @@ -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"