Add config flow to geo_json_events (#87062)

* migrated geo_json_events integration to config flow

* improve test coverage

* code reformatting

* fix tests

* fix entity manager

* changes after review

* improve test coverage and fixed form

* remove unused code

* remove commented out code

* changes after review

* make title prettier

* fixed tests

* simplified code

* changes after review

* fix test

* push deprecation out

* changes after review

* changes after review

* changes after review

* changes after review

* changes after review

* removed scan interval from user flow and import flow
This commit is contained in:
Malte Franken
2023-05-10 10:28:45 +02:00
committed by GitHub
parent 0c0d59d3e2
commit 649557ed2e
14 changed files with 632 additions and 265 deletions
@@ -1 +1,57 @@
"""The geo_json_events component."""
"""The GeoJSON events component."""
from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import (
async_entries_for_config_entry,
async_get,
)
from .const import DOMAIN, PLATFORMS
from .manager import GeoJsonFeedEntityManager
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up the GeoJSON events component as config entry."""
feeds = hass.data.setdefault(DOMAIN, {})
# Create feed entity manager for all platforms.
manager = GeoJsonFeedEntityManager(hass, config_entry)
feeds[config_entry.entry_id] = manager
_LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id)
await remove_orphaned_entities(hass, config_entry.entry_id)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
await manager.async_init()
return True
async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None:
"""Remove orphaned geo_location entities.
This is needed because when fetching data from the external feed this integration is
determining which entities need to be added, updated or removed by comparing the
current with the previous data. After a restart of Home Assistant the integration
has no previous data to compare against, and thus all entities managed by this
integration are removed after startup.
"""
entity_registry = async_get(hass)
orphaned_entries = async_entries_for_config_entry(entity_registry, entry_id)
if orphaned_entries is not None:
for entry in orphaned_entries:
if entry.domain == Platform.GEO_LOCATION:
_LOGGER.debug("Removing orphaned entry %s", entry.entity_id)
entity_registry.async_remove(entry.entity_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload the GeoJSON events config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id)
await manager.async_stop()
return unload_ok
@@ -0,0 +1,105 @@
"""Config flow to configure the GeoJSON events integration."""
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import (
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_RADIUS,
CONF_URL,
UnitOfLength,
)
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.util.unit_conversion import DistanceConverter
from .const import DEFAULT_RADIUS_IN_KM, DEFAULT_RADIUS_IN_M, DOMAIN
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): cv.string,
vol.Required(CONF_LOCATION): selector.LocationSelector(
selector.LocationSelectorConfig(radius=True, icon="")
),
}
)
_LOGGER = logging.getLogger(__name__)
class GeoJsonEventsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a GeoJSON events config flow."""
async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult:
"""Import a config entry from configuration.yaml."""
url: str = import_config[CONF_URL]
latitude: float = import_config.get(CONF_LATITUDE, self.hass.config.latitude)
longitude: float = import_config.get(CONF_LONGITUDE, self.hass.config.longitude)
self._async_abort_entries_match(
{
CONF_URL: url,
CONF_LATITUDE: latitude,
CONF_LONGITUDE: longitude,
}
)
return self.async_create_entry(
title=f"{url} ({latitude}, {longitude})",
data={
CONF_URL: url,
CONF_LATITUDE: latitude,
CONF_LONGITUDE: longitude,
CONF_RADIUS: import_config.get(CONF_RADIUS, DEFAULT_RADIUS_IN_KM),
},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the start of the config flow."""
if not user_input:
suggested_values: Mapping[str, Any] = {
CONF_LOCATION: {
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
CONF_RADIUS: DEFAULT_RADIUS_IN_M,
}
}
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, suggested_values
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
)
url: str = user_input[CONF_URL]
location: dict[str, Any] = user_input[CONF_LOCATION]
latitude: float = location[CONF_LATITUDE]
longitude: float = location[CONF_LONGITUDE]
self._async_abort_entries_match(
{
CONF_URL: url,
CONF_LATITUDE: latitude,
CONF_LONGITUDE: longitude,
}
)
return self.async_create_entry(
title=f"{url} ({latitude}, {longitude})",
data={
CONF_URL: url,
CONF_LATITUDE: latitude,
CONF_LONGITUDE: longitude,
CONF_RADIUS: DistanceConverter.convert(
location[CONF_RADIUS],
UnitOfLength.METERS,
UnitOfLength.KILOMETERS,
),
},
)
@@ -4,11 +4,16 @@ from __future__ import annotations
from datetime import timedelta
from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "geo_json_events"
PLATFORMS: Final = [Platform.GEO_LOCATION]
ATTR_EXTERNAL_ID: Final = "external_id"
DEFAULT_RADIUS_IN_KM: Final = 20.0
DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=5)
DEFAULT_RADIUS_IN_M: Final = 20000.0
DEFAULT_UPDATE_INTERVAL: Final = timedelta(seconds=300)
SOURCE: Final = "geo_json_events"
SIGNAL_DELETE_ENTITY: Final = "geo_json_events_delete_{}"
@@ -2,42 +2,41 @@
from __future__ import annotations
from collections.abc import Callable
from datetime import timedelta
import logging
from typing import Any
from aio_geojson_generic_client import GenericFeedManager
from aio_geojson_generic_client.feed_entry import GenericFeedEntry
import voluptuous as vol
from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import (
CONF_LATITUDE,
CONF_LONGITUDE,
CONF_RADIUS,
CONF_SCAN_INTERVAL,
CONF_URL,
EVENT_HOMEASSISTANT_START,
UnitOfLength,
)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import GeoJsonFeedEntityManager
from .const import (
ATTR_EXTERNAL_ID,
DEFAULT_RADIUS_IN_KM,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
SIGNAL_DELETE_ENTITY,
SIGNAL_UPDATE_ENTITY,
SOURCE,
)
from .manager import GeoJsonFeedEntityManager
_LOGGER = logging.getLogger(__name__)
# Deprecated.
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_URL): cv.string,
@@ -48,28 +47,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the GeoJSON Events platform."""
url: str = config[CONF_URL]
scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
coordinates: tuple[float, float] = (
config.get(CONF_LATITUDE, hass.config.latitude),
config.get(CONF_LONGITUDE, hass.config.longitude),
)
radius_in_km: float = config[CONF_RADIUS]
# Initialize the entity manager.
manager = GeoJsonFeedEntityManager(
hass, scan_interval, coordinates, url, radius_in_km
)
manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id]
@callback
def async_add_geolocation(
feed_manager: GenericFeedManager,
feed_manager: GeoJsonFeedEntityManager,
external_id: str,
) -> None:
"""Add geolocation entity from feed."""
@@ -77,15 +63,36 @@ async def async_setup_platform(
_LOGGER.debug("Adding geolocation %s", new_entity)
async_add_entities([new_entity], True)
async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation)
manager.listeners.append(
async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation)
)
# Do not wait for update here so that the setup can be completed and because an
# update will fetch data from the feed via HTTP and then process that data.
entry.async_create_task(hass, manager.async_update())
_LOGGER.debug("Geolocation setup done")
await manager.async_init()
async def start_feed_manager(event: Event) -> None:
"""Start feed manager."""
await manager.async_update()
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the GeoJSON Events platform."""
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml",
breaks_in_ha_version="2023.8.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
)
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
)
class GeoJsonLocationEvent(GeolocationEvent):
@@ -95,10 +102,15 @@ class GeoJsonLocationEvent(GeolocationEvent):
_attr_source = SOURCE
_attr_unit_of_measurement = UnitOfLength.KILOMETERS
def __init__(self, feed_manager: GenericFeedManager, external_id: str) -> None:
def __init__(
self,
feed_manager: GeoJsonFeedEntityManager,
external_id: str,
) -> None:
"""Initialize entity with data from feed entry."""
self._feed_manager = feed_manager
self._external_id = external_id
self._attr_unique_id = f"{feed_manager.entry_id}_{external_id}"
self._remove_signal_delete: Callable[[], None]
self._remove_signal_update: Callable[[], None]
@@ -1,18 +1,26 @@
"""Entity manager for generic GeoJSON events."""
from __future__ import annotations
from datetime import datetime, timedelta
from collections.abc import Callable
from datetime import datetime
import logging
from aio_geojson_generic_client import GenericFeedManager
from aio_geojson_generic_client.feed_entry import GenericFeedEntry
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from .const import DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY
from .const import (
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
SIGNAL_DELETE_ENTITY,
SIGNAL_UPDATE_ENTITY,
)
_LOGGER = logging.getLogger(__name__)
@@ -23,27 +31,28 @@ class GeoJsonFeedEntityManager:
def __init__(
self,
hass: HomeAssistant,
scan_interval: timedelta,
coordinates: tuple[float, float],
url: str,
radius_in_km: float,
config_entry: ConfigEntry,
) -> None:
"""Initialize the GeoJSON Feed Manager."""
self._hass = hass
self._hass: HomeAssistant = hass
self.entry_id: str = config_entry.entry_id
websession = aiohttp_client.async_get_clientsession(hass)
self._feed_manager = GenericFeedManager(
self._feed_manager: GenericFeedManager = GenericFeedManager(
websession,
self._generate_entity,
self._update_entity,
self._remove_entity,
coordinates,
url,
filter_radius=radius_in_km,
(
config_entry.data[CONF_LATITUDE],
config_entry.data[CONF_LONGITUDE],
),
config_entry.data[CONF_URL],
filter_radius=config_entry.data[CONF_RADIUS],
)
self._scan_interval = scan_interval
self.signal_new_entity = (
f"{DOMAIN}_new_geolocation_{coordinates}-{url}-{radius_in_km}"
self._track_time_remove_callback: Callable[[], None] | None = None
self.listeners: list[Callable[[], None]] = []
self.signal_new_entity: str = (
f"{DOMAIN}_new_geolocation_{config_entry.entry_id}"
)
async def async_init(self) -> None:
@@ -54,7 +63,10 @@ class GeoJsonFeedEntityManager:
await self.async_update()
# Trigger updates at regular intervals.
async_track_time_interval(self._hass, update, self._scan_interval)
self._track_time_remove_callback = async_track_time_interval(
self._hass, update, DEFAULT_UPDATE_INTERVAL
)
_LOGGER.debug("Feed entity manager initialized")
async def async_update(self) -> None:
@@ -62,6 +74,15 @@ class GeoJsonFeedEntityManager:
await self._feed_manager.update()
_LOGGER.debug("Feed entity manager updated")
async def async_stop(self) -> None:
"""Stop this feed entity manager from refreshing."""
for unsub_dispatcher in self.listeners:
unsub_dispatcher()
self.listeners = []
if self._track_time_remove_callback:
self._track_time_remove_callback()
_LOGGER.debug("Feed entity manager stopped")
def get_entry(self, external_id: str) -> GenericFeedEntry | None:
"""Get feed entry by external id."""
return self._feed_manager.feed_entries.get(external_id)
@@ -2,6 +2,7 @@
"domain": "geo_json_events",
"name": "GeoJSON",
"codeowners": ["@exxamalte"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/geo_json_events",
"integration_type": "service",
"iot_class": "cloud_polling",
@@ -0,0 +1,22 @@
{
"config": {
"step": {
"user": {
"title": "Fill in your GeoJSON feed details.",
"data": {
"url": "[%key:common::config_flow::data::url%]",
"location": "[%key:common::config_flow::data::location%]"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"issues": {
"deprecated_yaml": {
"title": "The GeoJSON feed YAML configuration is being removed",
"description": "Configuring a GeoJSON feed using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the GeoJSON feed YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
}
}
}