Refactor Météo-France to use API instead of web scraping (#37737)

* 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 <polletquentin74@me.com>

* 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 <polletquentin74@me.com>

* 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 <polletquentin74@me.com>

* 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 <polletquentin74@me.com>
This commit is contained in:
Oncleben31 2020-08-01 22:56:00 +02:00 committed by GitHub
parent 607ba08e23
commit 6b85e23408
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 827 additions and 371 deletions

View File

@ -243,7 +243,7 @@ homeassistant/components/mediaroom/* @dgomes
homeassistant/components/melcloud/* @vilppuvuorinen homeassistant/components/melcloud/* @vilppuvuorinen
homeassistant/components/melissa/* @kennedyshead homeassistant/components/melissa/* @kennedyshead
homeassistant/components/met/* @danielhiversen 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/meteoalarm/* @rolfberkenbosch
homeassistant/components/metoffice/* @MrHarcombe homeassistant/components/metoffice/* @MrHarcombe
homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel

View File

@ -1,22 +1,31 @@
"""Support for Meteo-France weather data.""" """Support for Meteo-France weather data."""
import asyncio import asyncio
import datetime from datetime import timedelta
import logging import logging
from meteofrance.client import meteofranceClient, meteofranceError from meteofrance.client import MeteoFranceClient
from vigilancemeteo import VigilanceMeteoError, VigilanceMeteoFranceProxy
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry 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 import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, HomeAssistantType 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__) _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}) 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: async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Set up Meteo-France from legacy config file.""" """Set up Meteo-France from legacy config file."""
conf = config.get(DOMAIN) conf = config.get(DOMAIN)
if conf is None: if not conf:
return True return True
for city_conf in conf: for city_conf in conf:
hass.async_create_task( hass.async_create_task(
hass.config_entries.flow.async_init( 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.""" """Set up an Meteo-France account from a config entry."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
# Weather alert latitude = entry.data.get(CONF_LATITUDE)
weather_alert_client = VigilanceMeteoFranceProxy()
try: client = MeteoFranceClient()
await hass.async_add_executor_job(weather_alert_client.update_data) # Migrate from previous config
except VigilanceMeteoError as exp: if not latitude:
_LOGGER.error( places = await hass.async_add_executor_job(
"Unexpected error when creating the vigilance_meteoFrance proxy: %s ", exp 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 latitude = entry.data[CONF_LATITUDE]
city = entry.data[CONF_CITY] longitude = entry.data[CONF_LONGITUDE]
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
hass.data[DOMAIN][city] = MeteoFranceUpdater(client) async def _async_update_data_forecast_forecast():
await hass.async_add_executor_job(hass.data[DOMAIN][city].update) """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: for platform in PLATFORMS:
hass.async_create_task( hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, platform) hass.config_entries.async_forward_entry_setup(entry, platform)
) )
_LOGGER.debug("meteo_france sensor platform loaded for %s", city)
return True return True
async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
"""Unload a config entry.""" """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( unload_ok = all(
await asyncio.gather( await asyncio.gather(
*[ *[
@ -88,29 +192,8 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
) )
) )
if unload_ok: 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 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
)

View File

@ -1,12 +1,15 @@
"""Config flow to configure the Meteo-France integration.""" """Config flow to configure the Meteo-France integration."""
import logging import logging
from meteofrance.client import meteofranceClient, meteofranceError from meteofrance.client import MeteoFranceClient
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries 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 from .const import DOMAIN # pylint: disable=unused-import
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -18,7 +21,13 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL 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.""" """Show the setup form to the user."""
if user_input is None: if user_input is None:
@ -37,26 +46,89 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors = {} errors = {}
if user_input is None: 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 = 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: if not latitude:
client = await self.hass.async_add_executor_job(meteofranceClient, city) client = MeteoFranceClient()
city_name = client.get_data()["name"] places = await self.hass.async_add_executor_job(client.search_places, city)
except meteofranceError as exp: _LOGGER.debug("places search result: %s", places)
_LOGGER.error( if not places:
"Unexpected error when creating the meteofrance proxy: %s", exp errors[CONF_CITY] = "empty"
) return await self._show_setup_form(user_input, errors)
return self.async_abort(reason="unknown")
return await self.async_step_cities(places=places)
# Check if already configured # 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() 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): async def async_step_import(self, user_input):
"""Import a config entry.""" """Import a config entry."""
return await self.async_step_user(user_input) 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}"

View File

@ -1,90 +1,127 @@
"""Meteo-France component constants.""" """Meteo-France component constants."""
from homeassistant.const import ( from homeassistant.const import (
PRESSURE_HPA,
SPEED_KILOMETERS_PER_HOUR, SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS, TEMP_CELSIUS,
TIME_MINUTES,
UNIT_PERCENTAGE, UNIT_PERCENTAGE,
) )
DOMAIN = "meteo_france" DOMAIN = "meteo_france"
PLATFORMS = ["sensor", "weather"] PLATFORMS = ["sensor", "weather"]
COORDINATOR_FORECAST = "coordinator_forecast"
COORDINATOR_RAIN = "coordinator_rain"
COORDINATOR_ALERT = "coordinator_alert"
ATTRIBUTION = "Data provided by Météo-France" ATTRIBUTION = "Data provided by Météo-France"
CONF_CITY = "city" 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 = { 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": { "rain_chance": {
SENSOR_TYPE_NAME: "Rain chance", ENTITY_NAME: "Rain chance",
SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, ENTITY_UNIT: UNIT_PERCENTAGE,
SENSOR_TYPE_ICON: "mdi:weather-rainy", ENTITY_ICON: "mdi:weather-rainy",
SENSOR_TYPE_CLASS: None, ENTITY_CLASS: None,
}, ENTITY_ENABLE: True,
"freeze_chance": { ENTITY_API_DATA_PATH: "probability_forecast:rain:3h",
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,
}, },
"snow_chance": { "snow_chance": {
SENSOR_TYPE_NAME: "Snow chance", ENTITY_NAME: "Snow chance",
SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, ENTITY_UNIT: UNIT_PERCENTAGE,
SENSOR_TYPE_ICON: "mdi:weather-snowy", ENTITY_ICON: "mdi:weather-snowy",
SENSOR_TYPE_CLASS: None, ENTITY_CLASS: None,
ENTITY_ENABLE: True,
ENTITY_API_DATA_PATH: "probability_forecast:snow:3h",
}, },
"weather": { "freeze_chance": {
SENSOR_TYPE_NAME: "Weather", ENTITY_NAME: "Freeze chance",
SENSOR_TYPE_UNIT: None, ENTITY_UNIT: UNIT_PERCENTAGE,
SENSOR_TYPE_ICON: "mdi:weather-partly-cloudy", ENTITY_ICON: "mdi:snowflake",
SENSOR_TYPE_CLASS: None, ENTITY_CLASS: None,
ENTITY_ENABLE: True,
ENTITY_API_DATA_PATH: "probability_forecast:freezing",
}, },
"wind_speed": { "wind_speed": {
SENSOR_TYPE_NAME: "Wind Speed", ENTITY_NAME: "Wind speed",
SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, ENTITY_UNIT: SPEED_KILOMETERS_PER_HOUR,
SENSOR_TYPE_ICON: "mdi:weather-windy", ENTITY_ICON: "mdi:weather-windy",
SENSOR_TYPE_CLASS: None, ENTITY_CLASS: None,
ENTITY_ENABLE: False,
ENTITY_API_DATA_PATH: "current_forecast:wind:speed",
}, },
"next_rain": { "next_rain": {
SENSOR_TYPE_NAME: "Next rain", ENTITY_NAME: "Next rain",
SENSOR_TYPE_UNIT: TIME_MINUTES, ENTITY_UNIT: None,
SENSOR_TYPE_ICON: "mdi:weather-rainy", ENTITY_ICON: "mdi:weather-pouring",
SENSOR_TYPE_CLASS: None, ENTITY_CLASS: "timestamp",
ENTITY_ENABLE: True,
ENTITY_API_DATA_PATH: None,
}, },
"temperature": { "temperature": {
SENSOR_TYPE_NAME: "Temperature", ENTITY_NAME: "Temperature",
SENSOR_TYPE_UNIT: TEMP_CELSIUS, ENTITY_UNIT: TEMP_CELSIUS,
SENSOR_TYPE_ICON: "mdi:thermometer", ENTITY_ICON: "mdi:thermometer",
SENSOR_TYPE_CLASS: "temperature", ENTITY_CLASS: "temperature",
ENTITY_ENABLE: False,
ENTITY_API_DATA_PATH: "current_forecast:T:value",
}, },
"uv": { "uv": {
SENSOR_TYPE_NAME: "UV", ENTITY_NAME: "UV",
SENSOR_TYPE_UNIT: None, ENTITY_UNIT: None,
SENSOR_TYPE_ICON: "mdi:sunglasses", ENTITY_ICON: "mdi:sunglasses",
SENSOR_TYPE_CLASS: None, ENTITY_CLASS: None,
ENTITY_ENABLE: True,
ENTITY_API_DATA_PATH: "today_forecast:uv",
}, },
"weather_alert": { "weather_alert": {
SENSOR_TYPE_NAME: "Weather Alert", ENTITY_NAME: "Weather alert",
SENSOR_TYPE_UNIT: None, ENTITY_UNIT: None,
SENSOR_TYPE_ICON: "mdi:weather-cloudy-alert", ENTITY_ICON: "mdi:weather-cloudy-alert",
SENSOR_TYPE_CLASS: None, 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 = { CONDITION_CLASSES = {
"clear-night": ["Nuit Claire", "Nuit claire"], "clear-night": ["Nuit Claire", "Nuit claire"],
"cloudy": ["Très nuageux"], "cloudy": ["Très nuageux", "Couvert"],
"fog": [ "fog": [
"Brume ou bancs de brouillard", "Brume ou bancs de brouillard",
"Brume", "Brume",
@ -94,7 +131,13 @@ CONDITION_CLASSES = {
"hail": ["Risque de grêle"], "hail": ["Risque de grêle"],
"lightning": ["Risque d'orages", "Orages"], "lightning": ["Risque d'orages", "Orages"],
"lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"], "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"], "pouring": ["Pluie forte"],
"rainy": [ "rainy": [
"Bruine / Pluie faible", "Bruine / Pluie faible",

View File

@ -3,6 +3,6 @@
"name": "Météo-France", "name": "Météo-France",
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/meteo_france", "documentation": "https://www.home-assistant.io/integrations/meteo_france",
"requirements": ["meteofrance==0.3.7", "vigilancemeteo==3.0.1"], "requirements": ["meteofrance-api==0.1.0"],
"codeowners": ["@victorcerutti", "@oncleben31", "@Quentame"] "codeowners": ["@hacf-fr", "@oncleben31", "@Quentame"]
} }

View File

@ -1,168 +1,231 @@
"""Support for Meteo-France raining forecast sensor.""" """Support for Meteo-France raining forecast sensor."""
import logging import logging
from meteofrance.client import meteofranceClient from meteofrance.helpers import (
from vigilancemeteo import DepartmentWeatherAlert, VigilanceMeteoFranceProxy get_warning_text_status_from_indice_color,
readeable_phenomenoms_dict,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
from .const import ( from .const import (
ATTR_NEXT_RAIN_1_HOUR_FORECAST,
ATTRIBUTION, ATTRIBUTION,
CONF_CITY, COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN, DOMAIN,
SENSOR_TYPE_CLASS, ENTITY_API_DATA_PATH,
SENSOR_TYPE_ICON, ENTITY_CLASS,
SENSOR_TYPE_NAME, ENTITY_ENABLE,
SENSOR_TYPE_UNIT, ENTITY_ICON,
ENTITY_NAME,
ENTITY_UNIT,
SENSOR_TYPES, SENSOR_TYPES,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
STATE_ATTR_FORECAST = "1h rain forecast"
STATE_ATTR_BULLETIN_TIME = "Bulletin date"
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
) -> None: ) -> None:
"""Set up the Meteo-France sensor platform.""" """Set up the Meteo-France sensor platform."""
city = entry.data[CONF_CITY] coordinator_forecast = hass.data[DOMAIN][entry.entry_id][COORDINATOR_FORECAST]
client = hass.data[DOMAIN][city] coordinator_rain = hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN]
weather_alert_client = hass.data[DOMAIN]["weather_alert_client"] coordinator_alert = hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]
alert_watcher = None entities = []
datas = client.get_data() for sensor_type in SENSOR_TYPES:
# Check if a department code is available for this city. if sensor_type == "next_rain":
if "dept" in datas: if coordinator_rain:
try: entities.append(MeteoFranceRainSensor(sensor_type, coordinator_rain))
# If yes create the watcher DepartmentWeatherAlert object.
alert_watcher = await hass.async_add_executor_job( elif sensor_type == "weather_alert":
DepartmentWeatherAlert, datas["dept"], weather_alert_client if coordinator_alert:
) entities.append(MeteoFranceAlertSensor(sensor_type, coordinator_alert))
_LOGGER.info(
"Weather alert watcher added for %s in department %s", elif sensor_type in ["rain_chance", "freeze_chance", "snow_chance"]:
city, if coordinator_forecast.data.probability_forecast:
datas["dept"], entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast))
) else:
except ValueError as exp: _LOGGER.warning(
_LOGGER.error( "Sensor %s skipped for %s as data is missing in the API",
"Unexpected error when creating the weather alert sensor for %s in department %s: %s", sensor_type,
city, coordinator_forecast.data.position["name"],
datas["dept"], )
exp,
) else:
else: entities.append(MeteoFranceSensor(sensor_type, coordinator_forecast))
_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
async_add_entities( async_add_entities(
[ entities, False,
MeteoFranceSensor(sensor_type, client, alert_watcher)
for sensor_type in SENSOR_TYPES
],
True,
) )
class MeteoFranceSensor(Entity): class MeteoFranceSensor(Entity):
"""Representation of a Meteo-France sensor.""" """Representation of a Meteo-France sensor."""
def __init__( def __init__(self, sensor_type: str, coordinator: DataUpdateCoordinator):
self,
sensor_type: str,
client: meteofranceClient,
alert_watcher: VigilanceMeteoFranceProxy,
):
"""Initialize the Meteo-France sensor.""" """Initialize the Meteo-France sensor."""
self._type = sensor_type self._type = sensor_type
self._client = client self.coordinator = coordinator
self._alert_watcher = alert_watcher city_name = self.coordinator.data.position["name"]
self._state = None self._name = f"{city_name} {SENSOR_TYPES[self._type][ENTITY_NAME]}"
self._data = {} self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}_{self._type}"
@property
def name(self):
"""Return the name of the sensor."""
return f"{self._data['name']} {SENSOR_TYPES[self._type][SENSOR_TYPE_NAME]}"
@property @property
def unique_id(self): def unique_id(self):
"""Return the unique id of the sensor.""" """Return the unique id."""
return self.name return self._unique_id
@property
def name(self):
"""Return the name."""
return self._name
@property @property
def state(self): def state(self):
"""Return the state of the sensor.""" """Return the state."""
return self._state path = SENSOR_TYPES[self._type][ENTITY_API_DATA_PATH].split(":")
data = getattr(self.coordinator.data, path[0])
@property # Specific case for probability forecast
def device_state_attributes(self): if path[0] == "probability_forecast":
"""Return the state attributes of the sensor.""" if len(path) == 3:
# Attributes for next_rain sensor. # This is a fix compared to other entitty as first index is always null in API result for unknown reason
if self._type == "next_rain" and "rain_forecast" in self._data: value = _find_first_probability_forecast_not_null(data, path)
return { else:
**{STATE_ATTR_FORECAST: self._data["rain_forecast"]}, value = data[0][path[1]]
**self._data["next_rain_intervals"],
**{ATTR_ATTRIBUTION: ATTRIBUTION},
}
# Attributes for weather_alert sensor. # General case
if self._type == "weather_alert" and self._alert_watcher is not None: else:
return { if len(path) == 3:
**{STATE_ATTR_BULLETIN_TIME: self._alert_watcher.bulletin_date}, value = data[path[1]][path[2]]
**self._alert_watcher.alerts_list, else:
ATTR_ATTRIBUTION: ATTRIBUTION, value = data[path[1]]
}
# Attributes for all other sensors. if self._type == "wind_speed":
return {ATTR_ATTRIBUTION: ATTRIBUTION} # convert API wind speed from m/s to km/h
value = round(value * 3.6)
return value
@property @property
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit of measurement.""" """Return the unit of measurement."""
return SENSOR_TYPES[self._type][SENSOR_TYPE_UNIT] return SENSOR_TYPES[self._type][ENTITY_UNIT]
@property @property
def icon(self): def icon(self):
"""Return the icon.""" """Return the icon."""
return SENSOR_TYPES[self._type][SENSOR_TYPE_ICON] return SENSOR_TYPES[self._type][ENTITY_ICON]
@property @property
def device_class(self): def device_class(self):
"""Return the device class of the sensor.""" """Return the device class."""
return SENSOR_TYPES[self._type][SENSOR_TYPE_CLASS] return SENSOR_TYPES[self._type][ENTITY_CLASS]
def update(self): @property
"""Fetch new state data for the sensor.""" def entity_registry_enabled_default(self) -> bool:
try: """Return if the entity should be enabled when first added to the entity registry."""
self._client.update() return SENSOR_TYPES[self._type][ENTITY_ENABLE]
self._data = self._client.get_data()
if self._type == "weather_alert": @property
if self._alert_watcher is not None: def device_state_attributes(self):
self._alert_watcher.update_department_status() """Return the state attributes."""
self._state = self._alert_watcher.department_color return {ATTR_ATTRIBUTION: ATTRIBUTION}
_LOGGER.debug(
"weather alert watcher for %s updated. Proxy have the status: %s", @property
self._data["name"], def available(self):
self._alert_watcher.proxy.status, """Return if state is available."""
) return self.coordinator.last_update_success
else:
_LOGGER.warning( @property
"No weather alert data for location %s", self._data["name"] def should_poll(self) -> bool:
) """No polling needed."""
else: return False
self._state = self._data[self._type]
except KeyError: async def async_update(self):
_LOGGER.error( """Only used by the generic entity update service."""
"No condition %s for location %s", self._type, self._data["name"] if not self.enabled:
) return
self._state = None
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

View File

@ -4,12 +4,33 @@
"user": { "user": {
"title": "M\u00e9t\u00e9o-France", "title": "M\u00e9t\u00e9o-France",
"description": "Enter the postal code (only for France, recommended) or city name", "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": { "abort": {
"already_configured": "City already configured", "already_configured": "City already configured",
"unknown": "Unknown error: please retry later" "unknown": "Unknown error: please retry later"
} }
},
"options": {
"step": {
"init": {
"data": {
"mode": "Forecast mode"
}
}
}
} }
} }

View File

@ -4,7 +4,17 @@
"already_configured": "City already configured", "already_configured": "City already configured",
"unknown": "Unknown error: please retry later" "unknown": "Unknown error: please retry later"
}, },
"error": {
"empty": "No result in city search: please check the city field"
},
"step": { "step": {
"cities": {
"data": {
"city": "City"
},
"description": "Choose your city from the list",
"title": "M\u00e9t\u00e9o-France"
},
"user": { "user": {
"data": { "data": {
"city": "City" "city": "City"
@ -13,5 +23,14 @@
"title": "M\u00e9t\u00e9o-France" "title": "M\u00e9t\u00e9o-France"
} }
} }
},
"options": {
"step": {
"init": {
"data": {
"mode": "Forecast mode"
}
}
}
} }
} }

View File

@ -1,88 +1,172 @@
"""Support for Meteo-France weather service.""" """Support for Meteo-France weather service."""
from datetime import timedelta
import logging import logging
import time
from meteofrance.client import meteofranceClient
from homeassistant.components.weather import ( from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION, ATTR_FORECAST_CONDITION,
ATTR_FORECAST_PRECIPITATION,
ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP,
ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TEMP_LOW,
ATTR_FORECAST_TIME, ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
ATTR_FORECAST_WIND_SPEED,
WeatherEntity, WeatherEntity,
) )
from homeassistant.config_entries import ConfigEntry 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 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__) _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( async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
) -> None: ) -> None:
"""Set up the Meteo-France weather platform.""" """Set up the Meteo-France weather platform."""
city = entry.data[CONF_CITY] coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR_FORECAST]
client = hass.data[DOMAIN][city]
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): class MeteoFranceWeather(WeatherEntity):
"""Representation of a weather condition.""" """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.""" """Initialise the platform with a data instance and station name."""
self._client = client self.coordinator = coordinator
self._data = {} self._city_name = self.coordinator.data.position["name"]
self._mode = mode
def update(self): self._unique_id = f"{self.coordinator.data.position['lat']},{self.coordinator.data.position['lon']}"
"""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"]
@property @property
def unique_id(self): def unique_id(self):
"""Return the unique id of the sensor.""" """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 @property
def condition(self): def condition(self):
"""Return the current condition.""" """Return the current condition."""
return self.format_condition(self._data["weather"]) return format_condition(
self.coordinator.data.current_forecast["weather"]["desc"]
)
@property @property
def temperature(self): def temperature(self):
"""Return the temperature.""" """Return the temperature."""
return self._data["temperature"] return self.coordinator.data.current_forecast["T"]["value"]
@property
def humidity(self):
"""Return the humidity."""
return None
@property @property
def temperature_unit(self): def temperature_unit(self):
"""Return the unit of measurement.""" """Return the unit of measurement."""
return TEMP_CELSIUS 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 @property
def wind_speed(self): def wind_speed(self):
"""Return the wind speed.""" """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 @property
def wind_bearing(self): def wind_bearing(self):
"""Return the wind bearing.""" """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 @property
def attribution(self): def attribution(self):
@ -90,36 +174,24 @@ class MeteoFranceWeather(WeatherEntity):
return ATTRIBUTION return ATTRIBUTION
@property @property
def forecast(self): def available(self):
"""Return the forecast.""" """Return if state is available."""
reftime = dt_util.utcnow().replace(hour=12, minute=0, second=0, microsecond=0) return self.coordinator.last_update_success
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
@property @property
def device_state_attributes(self): def should_poll(self) -> bool:
"""Return the state attributes.""" """No polling needed."""
data = {} return False
if self._data and "next_rain" in self._data:
data["next_rain"] = self._data["next_rain"] async def async_update(self):
return data """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)
)

View File

@ -902,7 +902,7 @@ messagebird==1.2.0
meteoalertapi==0.1.6 meteoalertapi==0.1.6
# homeassistant.components.meteo_france # homeassistant.components.meteo_france
meteofrance==0.3.7 meteofrance-api==0.1.0
# homeassistant.components.mfi # homeassistant.components.mfi
mficlient==0.3.0 mficlient==0.3.0
@ -2170,9 +2170,6 @@ vallox-websocket-api==2.4.0
# homeassistant.components.venstar # homeassistant.components.venstar
venstarcolortouch==0.12 venstarcolortouch==0.12
# homeassistant.components.meteo_france
vigilancemeteo==3.0.1
# homeassistant.components.vilfo # homeassistant.components.vilfo
vilfo-api-client==0.3.2 vilfo-api-client==0.3.2

View File

@ -421,7 +421,7 @@ mbddns==0.1.2
mcstatus==2.3.0 mcstatus==2.3.0
# homeassistant.components.meteo_france # homeassistant.components.meteo_france
meteofrance==0.3.7 meteofrance-api==0.1.0
# homeassistant.components.mfi # homeassistant.components.mfi
mficlient==0.3.0 mficlient==0.3.0
@ -960,9 +960,6 @@ url-normalize==1.4.1
# homeassistant.components.uvc # homeassistant.components.uvc
uvcclient==0.11.0 uvcclient==0.11.0
# homeassistant.components.meteo_france
vigilancemeteo==3.0.1
# homeassistant.components.vilfo # homeassistant.components.vilfo
vilfo-api-client==0.3.2 vilfo-api-client==0.3.2

View File

@ -7,10 +7,7 @@ from tests.async_mock import patch
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def patch_requests(): def patch_requests():
"""Stub out services that makes requests.""" """Stub out services that makes requests."""
patch_client = patch("homeassistant.components.meteo_france.meteofranceClient") patch_client = patch("homeassistant.components.meteo_france.MeteoFranceClient")
patch_weather_alert = patch(
"homeassistant.components.meteo_france.VigilanceMeteoFranceProxy"
)
with patch_client, patch_weather_alert: with patch_client:
yield yield

View File

@ -1,29 +1,82 @@
"""Tests for the Meteo-France config flow.""" """Tests for the Meteo-France config flow."""
from meteofrance.client import meteofranceError from meteofrance.model import Place
import pytest import pytest
from homeassistant import data_entry_flow 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.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.async_mock import patch
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
CITY_1_POSTAL = "74220" CITY_1_POSTAL = "74220"
CITY_1_NAME = "La Clusaz" CITY_1_NAME = "La Clusaz"
CITY_2_POSTAL_DISTRICT_1 = "69001" CITY_1_LAT = 45.90417
CITY_2_POSTAL_DISTRICT_4 = "69004" CITY_1_LON = 6.42306
CITY_2_NAME = "Lyon" 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") @pytest.fixture(name="client_single")
def mock_controller_client_1(): def mock_controller_client_single():
"""Mock a successful client.""" """Mock a successful client."""
with patch( with patch(
"homeassistant.components.meteo_france.config_flow.meteofranceClient", "homeassistant.components.meteo_france.config_flow.MeteoFranceClient",
update=False, update=False,
) as service_mock: ) 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 yield service_mock
@ -38,18 +91,29 @@ def mock_setup():
yield yield
@pytest.fixture(name="client_2") @pytest.fixture(name="client_multiple")
def mock_controller_client_2(): def mock_controller_client_multiple():
"""Mock a successful client.""" """Mock a successful client."""
with patch( with patch(
"homeassistant.components.meteo_france.config_flow.meteofranceClient", "homeassistant.components.meteo_france.config_flow.MeteoFranceClient",
update=False, update=False,
) as service_mock: ) 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 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.""" """Test user config."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER} 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["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user" 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( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL}, DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL},
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["result"].unique_id == CITY_1_NAME assert result["result"].unique_id == f"{CITY_1_LAT}, {CITY_1_LON}"
assert result["title"] == CITY_1_NAME assert result["title"] == f"{CITY_1}"
assert result["data"][CONF_CITY] == CITY_1_POSTAL 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.""" """Test import step."""
# import with all # import with all
result = await hass.config_entries.flow.async_init( 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["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["result"].unique_id == CITY_1_NAME assert result["result"].unique_id == f"{CITY_2_LAT}, {CITY_2_LON}"
assert result["title"] == CITY_1_NAME assert result["title"] == f"{CITY_2}"
assert result["data"][CONF_CITY] == CITY_1_POSTAL 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.""" """Test we abort if already setup."""
MockConfigEntry( 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) ).add_to_hass(hass)
# Should fail, same CITY same postal code (import) # 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" assert result["reason"] == "already_configured"
async def test_abort_if_already_setup_district(hass, client_2): async def test_options_flow(hass: HomeAssistantType):
"""Test we abort if already setup.""" """Test config flow options."""
MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_CITY: CITY_2_POSTAL_DISTRICT_1}, unique_id=CITY_2_NAME domain=DOMAIN,
).add_to_hass(hass) data={CONF_LATITUDE: CITY_1_LAT, CONF_LONGITUDE: CITY_1_LON},
unique_id=f"{CITY_1_LAT}, {CITY_1_LON}",
# 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},
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT config_entry.add_to_hass(hass)
assert result["reason"] == "already_configured"
# Should fail, same CITY different postal code (flow) assert config_entry.options == {}
result = await hass.config_entries.flow.async_init(
DOMAIN, result = await hass.config_entries.options.async_init(config_entry.entry_id)
context={"source": SOURCE_USER}, assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
data={CONF_CITY: CITY_2_POSTAL_DISTRICT_4}, 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["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["reason"] == "already_configured" assert config_entry.options[CONF_MODE] == FORECAST_MODE_DAILY
# Manual
async def test_client_failed(hass): result = await hass.config_entries.options.async_init(config_entry.entry_id)
"""Test when we have errors during client fetch.""" result = await hass.config_entries.options.async_configure(
with patch( result["flow_id"], user_input={CONF_MODE: FORECAST_MODE_HOURLY},
"homeassistant.components.meteo_france.config_flow.meteofranceClient", )
side_effect=meteofranceError(), assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
): assert config_entry.options[CONF_MODE] == FORECAST_MODE_HOURLY
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"