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/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

View File

@ -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
)

View File

@ -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}"

View File

@ -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",

View File

@ -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"]
}

View File

@ -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

View File

@ -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"
}
}
}
}
}
}

View File

@ -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"
}
}
}
}
}

View File

@ -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)
)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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