mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
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:
parent
607ba08e23
commit
6b85e23408
@ -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
|
||||||
|
@ -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
|
|
||||||
)
|
|
||||||
|
@ -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}"
|
||||||
|
@ -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",
|
||||||
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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)
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user