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
13 changed files with 827 additions and 371 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue