From 6b85e23408948626bc89a2f2298327769282f736 Mon Sep 17 00:00:00 2001 From: Oncleben31 Date: Sat, 1 Aug 2020 22:56:00 +0200 Subject: [PATCH] =?UTF-8?q?Refactor=20M=C3=A9t=C3=A9o-France=20to=20use=20?= =?UTF-8?q?API=20instead=20of=20web=20scraping=20(#37737)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new python library * Update requirements * Remove old libs * config flow with client.search_places * WIP: UI config + weather OK * WIP: sensors * WIP: add pressure to weather + available to sensor * WIP: coordinator next_rain + alert * Make import step working * migrate to meteofrance-api v0.0.3 * Create coordinator for rain only if data available in API * Fix avoid creation of rain sensor when not available. * Add options flow for forecast mode * Fix import config causing bug with UI * Add alert sensor * Add coastal alerts when available (#5) * Use meteofrance-api feature branch on Github * Update unit of next_rain sensor * Test different type of attibutes * Typo for attribute * Next rain sensor device class as timestamp * Better design for rain entity attributes * use master branch for meteofrance-api * time displayed in the HA server timezone. * fix bug when next_rain_date_locale is None * Add precipitation and cloud cover sensors * Add variable to avoid repeating computing * Apply suggestions from code review Co-authored-by: Quentame * Attributes names in const. * Cleaning * Cleaning: use current_forecast and today_forecast * Write state to HA after fetch * Refactor, Log messages and bug fix. (#6) * Add messages in log * Refactor using 'current_forecast'. * Use % string format with _LOGGER * Remove inconsistent path * Secure timestamp value and get current day forecast * new unique_id * Change Log message to debug * Log messages improvement * Don't try to create weather alert sensor if not in covered zone. * convert wind speed in km/h * Better list of city in config_flow * Manage initial CONF_MODE as None * Review correction * Review coorections * unique id correction * Migrate from previous config * Make config name detailed * Fix weather alert sensor unload (#7) * Unload weather alert platform * Revert "Unload weather alert platform" This reverts commit 95259fdee84f30a5be915eb1fbb2e19fcddc97e4. * second try in async_unload_entry * Make it work * isort modification * remove weather alert logic in sensor.py * Refactor to avoid too long code lines Co-authored-by: Quentin POLLET * Update config tests to Meteo-France (#18) * Update meteo_france exception name * Update MeteoFranceClient name used in tests * Update 'test_user' * Make test_user works * Add test test_user_list * Make test_import works * Quick & Dirty fix on exception managment. WIP * allow to catch MeteoFranceClient() exceptions * remove test_abort_if_already_setup_district * bump meteofrance-api version * We do not need to test Exception in flow yet * Remove unused data * Change client1 fixture name * Change client2 fixture name * Finish cities step * Test import with multiple choice * refactor places * Add option flow test Co-authored-by: Quentin POLLET * Fix errors due to missing data in the API (#22) * fix case where probability_forecast it not in API * Workaround for probabilty_forecast data null value * Fix weather alert sensor added when shouldn't * Add a partlycloudy and cloudy value options in condition map * Enable snow chance entity * fix from review * remove summary * Other fix from PR review * WIP: error if no results in city search * Add test for config_flow when no result in search * Lint fix * generate en.json * Update homeassistant/components/meteo_france/__init__.py * Update homeassistant/components/meteo_france/__init__.py * Update homeassistant/components/meteo_france/__init__.py * Update homeassistant/components/meteo_france/sensor.py * Update homeassistant/components/meteo_france/__init__.py * Update homeassistant/components/meteo_france/__init__.py * string: city input --> city field Co-authored-by: Quentin POLLET --- CODEOWNERS | 2 +- .../components/meteo_france/__init__.py | 187 ++++++++---- .../components/meteo_france/config_flow.py | 102 ++++++- .../components/meteo_france/const.py | 149 +++++---- .../components/meteo_france/manifest.json | 4 +- .../components/meteo_france/sensor.py | 289 +++++++++++------- .../components/meteo_france/strings.json | 25 +- .../meteo_france/translations/en.json | 19 ++ .../components/meteo_france/weather.py | 198 ++++++++---- requirements_all.txt | 5 +- requirements_test_all.txt | 5 +- tests/components/meteo_france/conftest.py | 7 +- .../meteo_france/test_config_flow.py | 206 +++++++++---- 13 files changed, 827 insertions(+), 371 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 10393f2ce17..e69cf26f073 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -243,7 +243,7 @@ homeassistant/components/mediaroom/* @dgomes homeassistant/components/melcloud/* @vilppuvuorinen homeassistant/components/melissa/* @kennedyshead homeassistant/components/met/* @danielhiversen -homeassistant/components/meteo_france/* @victorcerutti @oncleben31 @Quentame +homeassistant/components/meteo_france/* @hacf-fr @oncleben31 @Quentame homeassistant/components/meteoalarm/* @rolfberkenbosch homeassistant/components/metoffice/* @MrHarcombe homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index b7eda51b955..469c66ad79f 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -1,22 +1,31 @@ """Support for Meteo-France weather data.""" import asyncio -import datetime +from datetime import timedelta import logging -from meteofrance.client import meteofranceClient, meteofranceError -from vigilancemeteo import VigilanceMeteoError, VigilanceMeteoFranceProxy +from meteofrance.client import MeteoFranceClient import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import CONF_CITY, DOMAIN, PLATFORMS +from .const import ( + CONF_CITY, + COORDINATOR_ALERT, + COORDINATOR_FORECAST, + COORDINATOR_RAIN, + DOMAIN, + PLATFORMS, +) _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(minutes=5) +SCAN_INTERVAL_RAIN = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(minutes=15) CITY_SCHEMA = vol.Schema({vol.Required(CONF_CITY): cv.string}) @@ -28,15 +37,14 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up Meteo-France from legacy config file.""" - conf = config.get(DOMAIN) - if conf is None: + if not conf: return True for city_conf in conf: hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf.copy() + DOMAIN, context={"source": SOURCE_IMPORT}, data=city_conf ) ) @@ -47,38 +55,134 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool """Set up an Meteo-France account from a config entry.""" hass.data.setdefault(DOMAIN, {}) - # Weather alert - weather_alert_client = VigilanceMeteoFranceProxy() - try: - await hass.async_add_executor_job(weather_alert_client.update_data) - except VigilanceMeteoError as exp: - _LOGGER.error( - "Unexpected error when creating the vigilance_meteoFrance proxy: %s ", exp + latitude = entry.data.get(CONF_LATITUDE) + + client = MeteoFranceClient() + # Migrate from previous config + if not latitude: + places = await hass.async_add_executor_job( + client.search_places, entry.data[CONF_CITY] + ) + hass.config_entries.async_update_entry( + entry, + title=f"{places[0]}", + data={ + CONF_LATITUDE: places[0].latitude, + CONF_LONGITUDE: places[0].longitude, + }, ) - return False - hass.data[DOMAIN]["weather_alert_client"] = weather_alert_client - # Weather - city = entry.data[CONF_CITY] - try: - client = await hass.async_add_executor_job(meteofranceClient, city) - except meteofranceError as exp: - _LOGGER.error("Unexpected error when creating the meteofrance proxy: %s", exp) - return False + latitude = entry.data[CONF_LATITUDE] + longitude = entry.data[CONF_LONGITUDE] - hass.data[DOMAIN][city] = MeteoFranceUpdater(client) - await hass.async_add_executor_job(hass.data[DOMAIN][city].update) + async def _async_update_data_forecast_forecast(): + """Fetch data from API endpoint.""" + return await hass.async_add_job(client.get_forecast, latitude, longitude) + + async def _async_update_data_rain(): + """Fetch data from API endpoint.""" + return await hass.async_add_job(client.get_rain, latitude, longitude) + + async def _async_update_data_alert(): + """Fetch data from API endpoint.""" + return await hass.async_add_job( + client.get_warning_current_phenomenoms, department, 0, True + ) + + coordinator_forecast = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France forecast for city {entry.title}", + update_method=_async_update_data_forecast_forecast, + update_interval=SCAN_INTERVAL, + ) + coordinator_rain = None + coordinator_alert = None + + # Fetch initial data so we have data when entities subscribe + await coordinator_forecast.async_refresh() + + if not coordinator_forecast.last_update_success: + raise ConfigEntryNotReady + + # Check if rain forecast is available. + if coordinator_forecast.data.position.get("rain_product_available") == 1: + coordinator_rain = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France rain for city {entry.title}", + update_method=_async_update_data_rain, + update_interval=SCAN_INTERVAL_RAIN, + ) + await coordinator_rain.async_refresh() + + if not coordinator_rain.last_update_success: + raise ConfigEntryNotReady + else: + _LOGGER.warning( + "1 hour rain forecast not available. %s is not in covered zone", + entry.title, + ) + + department = coordinator_forecast.data.position.get("dept") + _LOGGER.debug( + "Department corresponding to %s is %s", entry.title, department, + ) + if department: + if not hass.data[DOMAIN].get(department): + coordinator_alert = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"Météo-France alert for department {department}", + update_method=_async_update_data_alert, + update_interval=SCAN_INTERVAL, + ) + + await coordinator_alert.async_refresh() + + if not coordinator_alert.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][department] = True + else: + _LOGGER.warning( + "Weather alert for department %s won't be added with city %s, as it has already been added within another city", + department, + entry.title, + ) + else: + _LOGGER.warning( + "Weather alert not available: The city %s is not in France or Andorre.", + entry.title, + ) + + hass.data[DOMAIN][entry.entry_id] = { + COORDINATOR_FORECAST: coordinator_forecast, + COORDINATOR_RAIN: coordinator_rain, + COORDINATOR_ALERT: coordinator_alert, + } for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, platform) ) - _LOGGER.debug("meteo_france sensor platform loaded for %s", city) + return True async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): """Unload a config entry.""" + if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]: + + department = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR_FORECAST + ].data.position.get("dept") + hass.data[DOMAIN][department] = False + _LOGGER.debug( + "Weather alert for depatment %s unloaded and released. It can be added now by another city.", + department, + ) + unload_ok = all( await asyncio.gather( *[ @@ -88,29 +192,8 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): ) ) if unload_ok: - hass.data[DOMAIN].pop(entry.data[CONF_CITY]) + hass.data[DOMAIN].pop(entry.entry_id) + if len(hass.data[DOMAIN]) == 0: + hass.data.pop(DOMAIN) return unload_ok - - -class MeteoFranceUpdater: - """Update data from Meteo-France.""" - - def __init__(self, client: meteofranceClient): - """Initialize the data object.""" - self._client = client - - def get_data(self): - """Get the latest data from Meteo-France.""" - return self._client.get_data() - - @Throttle(SCAN_INTERVAL) - def update(self): - """Get the latest data from Meteo-France.""" - - try: - self._client.update() - except meteofranceError as exp: - _LOGGER.error( - "Unexpected error when updating the meteofrance proxy: %s", exp - ) diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index c7673020360..73b1ea41089 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -1,12 +1,15 @@ """Config flow to configure the Meteo-France integration.""" import logging -from meteofrance.client import meteofranceClient, meteofranceError +from meteofrance.client import MeteoFranceClient import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.core import callback -from .const import CONF_CITY +from .const import CONF_CITY, FORECAST_MODE, FORECAST_MODE_DAILY from .const import DOMAIN # pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -18,7 +21,13 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - def _show_setup_form(self, user_input=None, errors=None): + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return MeteoFranceOptionsFlowHandler(config_entry) + + async def _show_setup_form(self, user_input=None, errors=None): """Show the setup form to the user.""" if user_input is None: @@ -37,26 +46,89 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is None: - return self._show_setup_form(user_input, errors) + return await self._show_setup_form(user_input, errors) city = user_input[CONF_CITY] # Might be a city name or a postal code - city_name = None + latitude = user_input.get(CONF_LATITUDE) + longitude = user_input.get(CONF_LONGITUDE) - try: - client = await self.hass.async_add_executor_job(meteofranceClient, city) - city_name = client.get_data()["name"] - except meteofranceError as exp: - _LOGGER.error( - "Unexpected error when creating the meteofrance proxy: %s", exp - ) - return self.async_abort(reason="unknown") + if not latitude: + client = MeteoFranceClient() + places = await self.hass.async_add_executor_job(client.search_places, city) + _LOGGER.debug("places search result: %s", places) + if not places: + errors[CONF_CITY] = "empty" + return await self._show_setup_form(user_input, errors) + + return await self.async_step_cities(places=places) # Check if already configured - await self.async_set_unique_id(city_name) + await self.async_set_unique_id(f"{latitude}, {longitude}") self._abort_if_unique_id_configured() - return self.async_create_entry(title=city_name, data={CONF_CITY: city}) + return self.async_create_entry( + title=city, data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude}, + ) async def async_step_import(self, user_input): """Import a config entry.""" return await self.async_step_user(user_input) + + async def async_step_cities(self, user_input=None, places=None): + """Step where the user choose the city from the API search results.""" + if places and len(places) > 1 and self.source != SOURCE_IMPORT: + places_for_form = {} + for place in places: + places_for_form[_build_place_key(place)] = f"{place}" + + return await self._show_cities_form(places_for_form) + # for import and only 1 city in the search result + if places and not user_input: + user_input = {CONF_CITY: _build_place_key(places[0])} + + city_infos = user_input.get(CONF_CITY).split(";") + return await self.async_step_user( + { + CONF_CITY: city_infos[0], + CONF_LATITUDE: city_infos[1], + CONF_LONGITUDE: city_infos[2], + } + ) + + async def _show_cities_form(self, cities): + """Show the form to choose the city.""" + return self.async_show_form( + step_id="cities", + data_schema=vol.Schema( + {vol.Required(CONF_CITY): vol.All(vol.Coerce(str), vol.In(cities))} + ), + ) + + +class MeteoFranceOptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + data_schema = vol.Schema( + { + vol.Optional( + CONF_MODE, + default=self.config_entry.options.get( + CONF_MODE, FORECAST_MODE_DAILY + ), + ): vol.In(FORECAST_MODE) + } + ) + return self.async_show_form(step_id="init", data_schema=data_schema) + + +def _build_place_key(place) -> str: + return f"{place};{place.latitude};{place.longitude}" diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 2edbf980f36..d1decb54078 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,90 +1,127 @@ """Meteo-France component constants.""" from homeassistant.const import ( + PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - TIME_MINUTES, UNIT_PERCENTAGE, ) DOMAIN = "meteo_france" PLATFORMS = ["sensor", "weather"] +COORDINATOR_FORECAST = "coordinator_forecast" +COORDINATOR_RAIN = "coordinator_rain" +COORDINATOR_ALERT = "coordinator_alert" ATTRIBUTION = "Data provided by Météo-France" CONF_CITY = "city" +FORECAST_MODE_HOURLY = "hourly" +FORECAST_MODE_DAILY = "daily" +FORECAST_MODE = [FORECAST_MODE_HOURLY, FORECAST_MODE_DAILY] -DEFAULT_WEATHER_CARD = True +ATTR_NEXT_RAIN_1_HOUR_FORECAST = "1_hour_forecast" + +ENTITY_NAME = "name" +ENTITY_UNIT = "unit" +ENTITY_ICON = "icon" +ENTITY_CLASS = "device_class" +ENTITY_ENABLE = "enable" +ENTITY_API_DATA_PATH = "data_path" -SENSOR_TYPE_NAME = "name" -SENSOR_TYPE_UNIT = "unit" -SENSOR_TYPE_ICON = "icon" -SENSOR_TYPE_CLASS = "device_class" SENSOR_TYPES = { + "pressure": { + ENTITY_NAME: "Pressure", + ENTITY_UNIT: PRESSURE_HPA, + ENTITY_ICON: "mdi:gauge", + ENTITY_CLASS: "pressure", + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:sea_level", + }, "rain_chance": { - SENSOR_TYPE_NAME: "Rain chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-rainy", - SENSOR_TYPE_CLASS: None, - }, - "freeze_chance": { - SENSOR_TYPE_NAME: "Freeze chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:snowflake", - SENSOR_TYPE_CLASS: None, - }, - "thunder_chance": { - SENSOR_TYPE_NAME: "Thunder chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-lightning", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Rain chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-rainy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:rain:3h", }, "snow_chance": { - SENSOR_TYPE_NAME: "Snow chance", - SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, - SENSOR_TYPE_ICON: "mdi:weather-snowy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Snow chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-snowy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:snow:3h", }, - "weather": { - SENSOR_TYPE_NAME: "Weather", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:weather-partly-cloudy", - SENSOR_TYPE_CLASS: None, + "freeze_chance": { + ENTITY_NAME: "Freeze chance", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:snowflake", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "probability_forecast:freezing", }, "wind_speed": { - SENSOR_TYPE_NAME: "Wind Speed", - SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, - SENSOR_TYPE_ICON: "mdi:weather-windy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Wind speed", + ENTITY_UNIT: SPEED_KILOMETERS_PER_HOUR, + ENTITY_ICON: "mdi:weather-windy", + ENTITY_CLASS: None, + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:wind:speed", }, "next_rain": { - SENSOR_TYPE_NAME: "Next rain", - SENSOR_TYPE_UNIT: TIME_MINUTES, - SENSOR_TYPE_ICON: "mdi:weather-rainy", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Next rain", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:weather-pouring", + ENTITY_CLASS: "timestamp", + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: None, }, "temperature": { - SENSOR_TYPE_NAME: "Temperature", - SENSOR_TYPE_UNIT: TEMP_CELSIUS, - SENSOR_TYPE_ICON: "mdi:thermometer", - SENSOR_TYPE_CLASS: "temperature", + ENTITY_NAME: "Temperature", + ENTITY_UNIT: TEMP_CELSIUS, + ENTITY_ICON: "mdi:thermometer", + ENTITY_CLASS: "temperature", + ENTITY_ENABLE: False, + ENTITY_API_DATA_PATH: "current_forecast:T:value", }, "uv": { - SENSOR_TYPE_NAME: "UV", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:sunglasses", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "UV", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:sunglasses", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "today_forecast:uv", }, "weather_alert": { - SENSOR_TYPE_NAME: "Weather Alert", - SENSOR_TYPE_UNIT: None, - SENSOR_TYPE_ICON: "mdi:weather-cloudy-alert", - SENSOR_TYPE_CLASS: None, + ENTITY_NAME: "Weather alert", + ENTITY_UNIT: None, + ENTITY_ICON: "mdi:weather-cloudy-alert", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: None, + }, + "precipitation": { + ENTITY_NAME: "Daily precipitation", + ENTITY_UNIT: "mm", + ENTITY_ICON: "mdi:cup-water", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "today_forecast:precipitation:24h", + }, + "cloud": { + ENTITY_NAME: "Cloud cover", + ENTITY_UNIT: UNIT_PERCENTAGE, + ENTITY_ICON: "mdi:weather-partly-cloudy", + ENTITY_CLASS: None, + ENTITY_ENABLE: True, + ENTITY_API_DATA_PATH: "current_forecast:clouds", }, } CONDITION_CLASSES = { "clear-night": ["Nuit Claire", "Nuit claire"], - "cloudy": ["Très nuageux"], + "cloudy": ["Très nuageux", "Couvert"], "fog": [ "Brume ou bancs de brouillard", "Brume", @@ -94,7 +131,13 @@ CONDITION_CLASSES = { "hail": ["Risque de grêle"], "lightning": ["Risque d'orages", "Orages"], "lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"], - "partlycloudy": ["Ciel voilé", "Ciel voilé nuit", "Éclaircies"], + "partlycloudy": [ + "Ciel voilé", + "Ciel voilé nuit", + "Éclaircies", + "Eclaircies", + "Peu nuageux", + ], "pouring": ["Pluie forte"], "rainy": [ "Bruine / Pluie faible", diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json index 5f12037e011..cd6f09246a6 100644 --- a/homeassistant/components/meteo_france/manifest.json +++ b/homeassistant/components/meteo_france/manifest.json @@ -3,6 +3,6 @@ "name": "Météo-France", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/meteo_france", - "requirements": ["meteofrance==0.3.7", "vigilancemeteo==3.0.1"], - "codeowners": ["@victorcerutti", "@oncleben31", "@Quentame"] + "requirements": ["meteofrance-api==0.1.0"], + "codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"] } diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index cf28b9ea558..39e33dafd65 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -1,168 +1,231 @@ """Support for Meteo-France raining forecast sensor.""" import logging -from meteofrance.client import meteofranceClient -from vigilancemeteo import DepartmentWeatherAlert, VigilanceMeteoFranceProxy +from meteofrance.helpers import ( + get_warning_text_status_from_indice_color, + readeable_phenomenoms_dict, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import dt as dt_util from .const import ( + ATTR_NEXT_RAIN_1_HOUR_FORECAST, ATTRIBUTION, - CONF_CITY, + COORDINATOR_ALERT, + COORDINATOR_FORECAST, + COORDINATOR_RAIN, DOMAIN, - SENSOR_TYPE_CLASS, - SENSOR_TYPE_ICON, - SENSOR_TYPE_NAME, - SENSOR_TYPE_UNIT, + ENTITY_API_DATA_PATH, + ENTITY_CLASS, + ENTITY_ENABLE, + ENTITY_ICON, + ENTITY_NAME, + ENTITY_UNIT, SENSOR_TYPES, ) _LOGGER = logging.getLogger(__name__) -STATE_ATTR_FORECAST = "1h rain forecast" -STATE_ATTR_BULLETIN_TIME = "Bulletin date" - async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ) -> None: """Set up the Meteo-France sensor platform.""" - city = entry.data[CONF_CITY] - client = hass.data[DOMAIN][city] - weather_alert_client = hass.data[DOMAIN]["weather_alert_client"] + coordinator_forecast = hass.data[DOMAIN][entry.entry_id][COORDINATOR_FORECAST] + coordinator_rain = hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] + coordinator_alert = hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] - alert_watcher = None - datas = client.get_data() - # Check if a department code is available for this city. - if "dept" in datas: - try: - # If yes create the watcher DepartmentWeatherAlert object. - alert_watcher = await hass.async_add_executor_job( - DepartmentWeatherAlert, datas["dept"], weather_alert_client - ) - _LOGGER.info( - "Weather alert watcher added for %s in department %s", - city, - datas["dept"], - ) - except ValueError as exp: - _LOGGER.error( - "Unexpected error when creating the weather alert sensor for %s in department %s: %s", - city, - datas["dept"], - exp, - ) - else: - _LOGGER.warning( - "No 'dept' key found for '%s'. So weather alert information won't be available", - city, - ) - # Exit and don't create the sensor if no department code available. - return + entities = [] + for sensor_type in SENSOR_TYPES: + if sensor_type == "next_rain": + if coordinator_rain: + entities.append(MeteoFranceRainSensor(sensor_type, coordinator_rain)) + + elif sensor_type == "weather_alert": + if coordinator_alert: + entities.append(MeteoFranceAlertSensor(sensor_type, coordinator_alert)) + + elif sensor_type in ["rain_chance", "freeze_chance", "snow_chance"]: + if coordinator_forecast.data.probability_forecast: + entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) + else: + _LOGGER.warning( + "Sensor %s skipped for %s as data is missing in the API", + sensor_type, + coordinator_forecast.data.position["name"], + ) + + else: + entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast)) async_add_entities( - [ - MeteoFranceSensor(sensor_type, client, alert_watcher) - for sensor_type in SENSOR_TYPES - ], - True, + entities, False, ) class MeteoFranceSensor(Entity): """Representation of a Meteo-France sensor.""" - def __init__( - self, - sensor_type: str, - client: meteofranceClient, - alert_watcher: VigilanceMeteoFranceProxy, - ): + def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator): """Initialize the Meteo-France sensor.""" self._type = sensor_type - self._client = client - self._alert_watcher = alert_watcher - self._state = None - self._data = {} - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._data['name']} {SENSOR_TYPES[self._type][SENSOR_TYPE_NAME]}" + self.coordinator = coordinator + city_name = self.coordinator.data.position["name"] + self._name = f"{city_name} {SENSOR_TYPES[self._type][ENTITY_NAME]}" + self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}_{self._type}" @property def unique_id(self): - """Return the unique id of the sensor.""" - return self.name + """Return the unique id.""" + return self._unique_id + + @property + def name(self): + """Return the name.""" + return self._name @property def state(self): - """Return the state of the sensor.""" - return self._state + """Return the state.""" + path = SENSOR_TYPES[self._type][ENTITY_API_DATA_PATH].split(":") + data = getattr(self.coordinator.data, path[0]) - @property - def device_state_attributes(self): - """Return the state attributes of the sensor.""" - # Attributes for next_rain sensor. - if self._type == "next_rain" and "rain_forecast" in self._data: - return { - **{STATE_ATTR_FORECAST: self._data["rain_forecast"]}, - **self._data["next_rain_intervals"], - **{ATTR_ATTRIBUTION: ATTRIBUTION}, - } + # Specific case for probability forecast + if path[0] == "probability_forecast": + if len(path) == 3: + # This is a fix compared to other entitty as first index is always null in API result for unknown reason + value = _find_first_probability_forecast_not_null(data, path) + else: + value = data[0][path[1]] - # Attributes for weather_alert sensor. - if self._type == "weather_alert" and self._alert_watcher is not None: - return { - **{STATE_ATTR_BULLETIN_TIME: self._alert_watcher.bulletin_date}, - **self._alert_watcher.alerts_list, - ATTR_ATTRIBUTION: ATTRIBUTION, - } + # General case + else: + if len(path) == 3: + value = data[path[1]][path[2]] + else: + value = data[path[1]] - # Attributes for all other sensors. - return {ATTR_ATTRIBUTION: ATTRIBUTION} + if self._type == "wind_speed": + # convert API wind speed from m/s to km/h + value = round(value * 3.6) + return value @property def unit_of_measurement(self): """Return the unit of measurement.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_UNIT] + return SENSOR_TYPES[self._type][ENTITY_UNIT] @property def icon(self): """Return the icon.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_ICON] + return SENSOR_TYPES[self._type][ENTITY_ICON] @property def device_class(self): - """Return the device class of the sensor.""" - return SENSOR_TYPES[self._type][SENSOR_TYPE_CLASS] + """Return the device class.""" + return SENSOR_TYPES[self._type][ENTITY_CLASS] - def update(self): - """Fetch new state data for the sensor.""" - try: - self._client.update() - self._data = self._client.get_data() + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return SENSOR_TYPES[self._type][ENTITY_ENABLE] - if self._type == "weather_alert": - if self._alert_watcher is not None: - self._alert_watcher.update_department_status() - self._state = self._alert_watcher.department_color - _LOGGER.debug( - "weather alert watcher for %s updated. Proxy have the status: %s", - self._data["name"], - self._alert_watcher.proxy.status, - ) - else: - _LOGGER.warning( - "No weather alert data for location %s", self._data["name"] - ) - else: - self._state = self._data[self._type] - except KeyError: - _LOGGER.error( - "No condition %s for location %s", self._type, self._data["name"] - ) - self._state = None + @property + def device_state_attributes(self): + """Return the state attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def available(self): + """Return if state is available.""" + return self.coordinator.last_update_success + + @property + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_update(self): + """Only used by the generic entity update service.""" + if not self.enabled: + return + + await self.coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + +class MeteoFranceRainSensor(MeteoFranceSensor): + """Representation of a Meteo-France rain sensor.""" + + @property + def state(self): + """Return the state.""" + next_rain_date_locale = self.coordinator.data.next_rain_date_locale() + return ( + dt_util.as_local(next_rain_date_locale) if next_rain_date_locale else None + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_NEXT_RAIN_1_HOUR_FORECAST: [ + { + dt_util.as_local( + self.coordinator.data.timestamp_to_locale_time(item["dt"]) + ).strftime("%H:%M"): item["desc"] + } + for item in self.coordinator.data.forecast + ], + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + +class MeteoFranceAlertSensor(MeteoFranceSensor): + """Representation of a Meteo-France alert sensor.""" + + # pylint: disable=super-init-not-called + def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator): + """Initialize the Meteo-France sensor.""" + self._type = sensor_type + self.coordinator = coordinator + dept_code = self.coordinator.data.domain_id + self._name = f"{dept_code} {SENSOR_TYPES[self._type][ENTITY_NAME]}" + self._unique_id = self._name + + @property + def state(self): + """Return the state.""" + return get_warning_text_status_from_indice_color( + self.coordinator.data.get_domain_max_color() + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + **readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors), + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + +def _find_first_probability_forecast_not_null( + probability_forecast: list, path: list +) -> int: + """Search the first not None value in the first forecast elements.""" + for forecast in probability_forecast[0:3]: + if forecast[path[1]][path[2]] is not None: + return forecast[path[1]][path[2]] + + # Default return value if no value founded + return None diff --git a/homeassistant/components/meteo_france/strings.json b/homeassistant/components/meteo_france/strings.json index fc6e426b8d4..611d1ca054c 100644 --- a/homeassistant/components/meteo_france/strings.json +++ b/homeassistant/components/meteo_france/strings.json @@ -4,12 +4,33 @@ "user": { "title": "M\u00e9t\u00e9o-France", "description": "Enter the postal code (only for France, recommended) or city name", - "data": { "city": "City" } + "data": { + "city": "City" + } + }, + "cities": { + "title": "M\u00e9t\u00e9o-France", + "description": "Choose your city from the list", + "data": { + "city": "City" + } } }, + "error": { + "empty": "No result in city search: please check the city field" + }, "abort": { "already_configured": "City already configured", "unknown": "Unknown error: please retry later" } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Forecast mode" + } + } + } } -} +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/translations/en.json b/homeassistant/components/meteo_france/translations/en.json index 7b161dcda07..979f705cc5b 100644 --- a/homeassistant/components/meteo_france/translations/en.json +++ b/homeassistant/components/meteo_france/translations/en.json @@ -4,7 +4,17 @@ "already_configured": "City already configured", "unknown": "Unknown error: please retry later" }, + "error": { + "empty": "No result in city search: please check the city field" + }, "step": { + "cities": { + "data": { + "city": "City" + }, + "description": "Choose your city from the list", + "title": "M\u00e9t\u00e9o-France" + }, "user": { "data": { "city": "City" @@ -13,5 +23,14 @@ "title": "M\u00e9t\u00e9o-France" } } + }, + "options": { + "step": { + "init": { + "data": { + "mode": "Forecast mode" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 2983c6b7d59..a9c4840901b 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -1,88 +1,172 @@ """Support for Meteo-France weather service.""" -from datetime import timedelta import logging - -from meteofrance.client import meteofranceClient +import time from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import CONF_MODE, TEMP_CELSIUS from homeassistant.helpers.typing import HomeAssistantType -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ATTRIBUTION, CONDITION_CLASSES, CONF_CITY, DOMAIN +from .const import ( + ATTRIBUTION, + CONDITION_CLASSES, + COORDINATOR_FORECAST, + DOMAIN, + FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, +) _LOGGER = logging.getLogger(__name__) +def format_condition(condition: str): + """Return condition from dict CONDITION_CLASSES.""" + for key, value in CONDITION_CLASSES.items(): + if condition in value: + return key + return condition + + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ) -> None: """Set up the Meteo-France weather platform.""" - city = entry.data[CONF_CITY] - client = hass.data[DOMAIN][city] + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR_FORECAST] - async_add_entities([MeteoFranceWeather(client)], True) + async_add_entities( + [ + MeteoFranceWeather( + coordinator, entry.options.get(CONF_MODE, FORECAST_MODE_DAILY), + ) + ], + True, + ) + _LOGGER.debug( + "Weather entity (%s) added for %s.", + entry.options.get(CONF_MODE, FORECAST_MODE_DAILY), + coordinator.data.position["name"], + ) class MeteoFranceWeather(WeatherEntity): """Representation of a weather condition.""" - def __init__(self, client: meteofranceClient): + def __init__(self, coordinator: DataUpdateCoordinator, mode: str): """Initialise the platform with a data instance and station name.""" - self._client = client - self._data = {} - - def update(self): - """Update current conditions.""" - self._client.update() - self._data = self._client.get_data() - - @property - def name(self): - """Return the name of the sensor.""" - return self._data["name"] + self.coordinator = coordinator + self._city_name = self.coordinator.data.position["name"] + self._mode = mode + self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}" @property def unique_id(self): """Return the unique id of the sensor.""" - return self.name + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return self._city_name @property def condition(self): """Return the current condition.""" - return self.format_condition(self._data["weather"]) + return format_condition( + self.coordinator.data.current_forecast["weather"]["desc"] + ) @property def temperature(self): """Return the temperature.""" - return self._data["temperature"] - - @property - def humidity(self): - """Return the humidity.""" - return None + return self.coordinator.data.current_forecast["T"]["value"] @property def temperature_unit(self): """Return the unit of measurement.""" return TEMP_CELSIUS + @property + def pressure(self): + """Return the pressure.""" + return self.coordinator.data.current_forecast["sea_level"] + + @property + def humidity(self): + """Return the humidity.""" + return self.coordinator.data.current_forecast["humidity"] + @property def wind_speed(self): """Return the wind speed.""" - return self._data["wind_speed"] + # convert from API m/s to km/h + return round(self.coordinator.data.current_forecast["wind"]["speed"] * 3.6) @property def wind_bearing(self): """Return the wind bearing.""" - return self._data["wind_bearing"] + wind_bearing = self.coordinator.data.current_forecast["wind"]["direction"] + if wind_bearing != -1: + return wind_bearing + + @property + def forecast(self): + """Return the forecast.""" + forecast_data = [] + + if self._mode == FORECAST_MODE_HOURLY: + today = time.time() + for forecast in self.coordinator.data.forecast: + # Can have data in the past + if forecast["dt"] < today: + _LOGGER.debug( + "remove forecast in the past: %s %s", self._mode, forecast + ) + continue + forecast_data.append( + { + ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( + forecast["dt"] + ), + ATTR_FORECAST_CONDITION: format_condition( + forecast["weather"]["desc"] + ), + ATTR_FORECAST_TEMP: forecast["T"]["value"], + ATTR_FORECAST_PRECIPITATION: forecast["rain"].get("1h"), + ATTR_FORECAST_WIND_SPEED: forecast["wind"]["speed"], + ATTR_FORECAST_WIND_BEARING: forecast["wind"]["direction"] + if forecast["wind"]["direction"] != -1 + else None, + } + ) + else: + for forecast in self.coordinator.data.daily_forecast: + # stop when we don't have a weather condition (can happen around last days of forcast, max 14) + if not forecast.get("weather12H"): + break + forecast_data.append( + { + ATTR_FORECAST_TIME: self.coordinator.data.timestamp_to_locale_time( + forecast["dt"] + ), + ATTR_FORECAST_CONDITION: format_condition( + forecast["weather12H"]["desc"] + ), + ATTR_FORECAST_TEMP: forecast["T"]["max"], + ATTR_FORECAST_TEMP_LOW: forecast["T"]["min"], + ATTR_FORECAST_PRECIPITATION: forecast["precipitation"]["24h"], + } + ) + return forecast_data @property def attribution(self): @@ -90,36 +174,24 @@ class MeteoFranceWeather(WeatherEntity): return ATTRIBUTION @property - def forecast(self): - """Return the forecast.""" - reftime = dt_util.utcnow().replace(hour=12, minute=0, second=0, microsecond=0) - reftime += timedelta(hours=24) - _LOGGER.debug("reftime used for %s forecast: %s", self._data["name"], reftime) - forecast_data = [] - for key in self._data["forecast"]: - value = self._data["forecast"][key] - data_dict = { - ATTR_FORECAST_TIME: reftime.isoformat(), - ATTR_FORECAST_TEMP: int(value["max_temp"]), - ATTR_FORECAST_TEMP_LOW: int(value["min_temp"]), - ATTR_FORECAST_CONDITION: self.format_condition(value["weather"]), - } - reftime = reftime + timedelta(hours=24) - forecast_data.append(data_dict) - return forecast_data - - @staticmethod - def format_condition(condition): - """Return condition from dict CONDITION_CLASSES.""" - for key, value in CONDITION_CLASSES.items(): - if condition in value: - return key - return condition + def available(self): + """Return if state is available.""" + return self.coordinator.last_update_success @property - def device_state_attributes(self): - """Return the state attributes.""" - data = {} - if self._data and "next_rain" in self._data: - data["next_rain"] = self._data["next_rain"] - return data + def should_poll(self) -> bool: + """No polling needed.""" + return False + + async def async_update(self): + """Only used by the generic entity update service.""" + if not self.enabled: + return + + await self.coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) diff --git a/requirements_all.txt b/requirements_all.txt index 6e5d32d25a0..1b24a561380 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -902,7 +902,7 @@ messagebird==1.2.0 meteoalertapi==0.1.6 # homeassistant.components.meteo_france -meteofrance==0.3.7 +meteofrance-api==0.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -2170,9 +2170,6 @@ vallox-websocket-api==2.4.0 # homeassistant.components.venstar venstarcolortouch==0.12 -# homeassistant.components.meteo_france -vigilancemeteo==3.0.1 - # homeassistant.components.vilfo vilfo-api-client==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc43253bc2a..7030b066848 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -421,7 +421,7 @@ mbddns==0.1.2 mcstatus==2.3.0 # homeassistant.components.meteo_france -meteofrance==0.3.7 +meteofrance-api==0.1.0 # homeassistant.components.mfi mficlient==0.3.0 @@ -960,9 +960,6 @@ url-normalize==1.4.1 # homeassistant.components.uvc uvcclient==0.11.0 -# homeassistant.components.meteo_france -vigilancemeteo==3.0.1 - # homeassistant.components.vilfo vilfo-api-client==0.3.2 diff --git a/tests/components/meteo_france/conftest.py b/tests/components/meteo_france/conftest.py index 75c294775ed..06a65b6ba87 100644 --- a/tests/components/meteo_france/conftest.py +++ b/tests/components/meteo_france/conftest.py @@ -7,10 +7,7 @@ from tests.async_mock import patch @pytest.fixture(autouse=True) def patch_requests(): """Stub out services that makes requests.""" - patch_client = patch("homeassistant.components.meteo_france.meteofranceClient") - patch_weather_alert = patch( - "homeassistant.components.meteo_france.VigilanceMeteoFranceProxy" - ) + patch_client = patch("homeassistant.components.meteo_france.MeteoFranceClient") - with patch_client, patch_weather_alert: + with patch_client: yield diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py index 8a5c734a0ed..650a88df84e 100644 --- a/tests/components/meteo_france/test_config_flow.py +++ b/tests/components/meteo_france/test_config_flow.py @@ -1,29 +1,82 @@ """Tests for the Meteo-France config flow.""" -from meteofrance.client import meteofranceError +from meteofrance.model import Place import pytest from homeassistant import data_entry_flow -from homeassistant.components.meteo_france.const import CONF_CITY, DOMAIN +from homeassistant.components.meteo_france.const import ( + CONF_CITY, + DOMAIN, + FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, +) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE +from homeassistant.helpers.typing import HomeAssistantType from tests.async_mock import patch from tests.common import MockConfigEntry CITY_1_POSTAL = "74220" CITY_1_NAME = "La Clusaz" -CITY_2_POSTAL_DISTRICT_1 = "69001" -CITY_2_POSTAL_DISTRICT_4 = "69004" -CITY_2_NAME = "Lyon" +CITY_1_LAT = 45.90417 +CITY_1_LON = 6.42306 +CITY_1_COUNTRY = "FR" +CITY_1_ADMIN = "Rhône-Alpes" +CITY_1_ADMIN2 = "74" +CITY_1 = Place( + { + "name": CITY_1_NAME, + "lat": CITY_1_LAT, + "lon": CITY_1_LON, + "country": CITY_1_COUNTRY, + "admin": CITY_1_ADMIN, + "admin2": CITY_1_ADMIN2, + } +) + +CITY_2_NAME = "Auch" +CITY_2_LAT = 43.64528 +CITY_2_LON = 0.58861 +CITY_2_COUNTRY = "FR" +CITY_2_ADMIN = "Midi-Pyrénées" +CITY_2_ADMIN2 = "32" +CITY_2 = Place( + { + "name": CITY_2_NAME, + "lat": CITY_2_LAT, + "lon": CITY_2_LON, + "country": CITY_2_COUNTRY, + "admin": CITY_2_ADMIN, + "admin2": CITY_2_ADMIN2, + } +) + +CITY_3_NAME = "Auchel" +CITY_3_LAT = 50.50833 +CITY_3_LON = 2.47361 +CITY_3_COUNTRY = "FR" +CITY_3_ADMIN = "Nord-Pas-de-Calais" +CITY_3_ADMIN2 = "62" +CITY_3 = Place( + { + "name": CITY_3_NAME, + "lat": CITY_3_LAT, + "lon": CITY_3_LON, + "country": CITY_3_COUNTRY, + "admin": CITY_3_ADMIN, + "admin2": CITY_3_ADMIN2, + } +) -@pytest.fixture(name="client_1") -def mock_controller_client_1(): +@pytest.fixture(name="client_single") +def mock_controller_client_single(): """Mock a successful client.""" with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", update=False, ) as service_mock: - service_mock.return_value.get_data.return_value = {"name": CITY_1_NAME} + service_mock.return_value.search_places.return_value = [CITY_1] yield service_mock @@ -38,18 +91,29 @@ def mock_setup(): yield -@pytest.fixture(name="client_2") -def mock_controller_client_2(): +@pytest.fixture(name="client_multiple") +def mock_controller_client_multiple(): """Mock a successful client.""" with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", update=False, ) as service_mock: - service_mock.return_value.get_data.return_value = {"name": CITY_2_NAME} + service_mock.return_value.search_places.return_value = [CITY_2, CITY_3] yield service_mock -async def test_user(hass, client_1): +@pytest.fixture(name="client_empty") +def mock_controller_client_empty(): + """Mock a successful client.""" + with patch( + "homeassistant.components.meteo_france.config_flow.MeteoFranceClient", + update=False, + ) as service_mock: + service_mock.return_value.search_places.return_value = [] + yield service_mock + + +async def test_user(hass, client_single): """Test user config.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -57,32 +121,67 @@ async def test_user(hass, client_1): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - # test with all provided + # test with all provided with search returning only 1 place result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == CITY_1_NAME - assert result["title"] == CITY_1_NAME - assert result["data"][CONF_CITY] == CITY_1_POSTAL + assert result["result"].unique_id == f"{CITY_1_LAT}, {CITY_1_LON}" + assert result["title"] == f"{CITY_1}" + assert result["data"][CONF_LATITUDE] == str(CITY_1_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_1_LON) -async def test_import(hass, client_1): +async def test_user_list(hass, client_multiple): + """Test user config.""" + + # test with all provided with search returning more than 1 place + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_2_NAME}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "cities" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CITY: f"{CITY_3};{CITY_3_LAT};{CITY_3_LON}"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].unique_id == f"{CITY_3_LAT}, {CITY_3_LON}" + assert result["title"] == f"{CITY_3}" + assert result["data"][CONF_LATITUDE] == str(CITY_3_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_3_LON) + + +async def test_import(hass, client_multiple): """Test import step.""" # import with all result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_1_POSTAL}, + DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_2_NAME}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["result"].unique_id == CITY_1_NAME - assert result["title"] == CITY_1_NAME - assert result["data"][CONF_CITY] == CITY_1_POSTAL + assert result["result"].unique_id == f"{CITY_2_LAT}, {CITY_2_LON}" + assert result["title"] == f"{CITY_2}" + assert result["data"][CONF_LATITUDE] == str(CITY_2_LAT) + assert result["data"][CONF_LONGITUDE] == str(CITY_2_LON) -async def test_abort_if_already_setup(hass, client_1): +async def test_search_failed(hass, client_empty): + """Test error displayed if no result in search.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_CITY: "empty"} + + +async def test_abort_if_already_setup(hass, client_single): """Test we abort if already setup.""" MockConfigEntry( - domain=DOMAIN, data={CONF_CITY: CITY_1_POSTAL}, unique_id=CITY_1_NAME + domain=DOMAIN, + data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON}, + unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", ).add_to_hass(hass) # Should fail, same CITY same postal code (import) @@ -100,39 +199,32 @@ async def test_abort_if_already_setup(hass, client_1): assert result["reason"] == "already_configured" -async def test_abort_if_already_setup_district(hass, client_2): - """Test we abort if already setup.""" - MockConfigEntry( - domain=DOMAIN, data={CONF_CITY: CITY_2_POSTAL_DISTRICT_1}, unique_id=CITY_2_NAME - ).add_to_hass(hass) - - # Should fail, same CITY different postal code (import) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4}, +async def test_options_flow(hass: HomeAssistantType): + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON}, + unique_id=f"{CITY_1_LAT}, {CITY_1_LON}", ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + config_entry.add_to_hass(hass) - # Should fail, same CITY different postal code (flow) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4}, + assert config_entry.options == {} + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + # Default + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={}, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_MODE] == FORECAST_MODE_DAILY - -async def test_client_failed(hass): - """Test when we have errors during client fetch.""" - with patch( - "homeassistant.components.meteo_france.config_flow.meteofranceClient", - side_effect=meteofranceError(), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "unknown" + # Manual + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_MODE: FORECAST_MODE_HOURLY}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options[CONF_MODE] == FORECAST_MODE_HOURLY