Scrape sub config entry

This commit is contained in:
G Johansson 2025-03-25 16:08:30 +00:00
parent 0aa09a2d51
commit b27ef88be7
3 changed files with 280 additions and 267 deletions

View File

@ -5,26 +5,29 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Coroutine from collections.abc import Coroutine
from datetime import timedelta from datetime import timedelta
import logging
from typing import Any from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import ( from homeassistant.const import (
CONF_ATTRIBUTE, CONF_ATTRIBUTE,
CONF_NAME,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import ( from homeassistant.helpers import (
config_validation as cv, config_validation as cv,
device_registry as dr,
discovery, discovery,
entity_registry as er, entity_registry as er,
) )
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.trigger_template_entity import ( from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY, CONF_AVAILABILITY,
TEMPLATE_SENSOR_BASE_SCHEMA, TEMPLATE_SENSOR_BASE_SCHEMA,
@ -36,6 +39,8 @@ from .coordinator import ScrapeCoordinator
type ScrapeConfigEntry = ConfigEntry[ScrapeCoordinator] type ScrapeConfigEntry = ConfigEntry[ScrapeCoordinator]
_LOGGER = logging.getLogger(__name__)
SENSOR_SCHEMA = vol.Schema( SENSOR_SCHEMA = vol.Schema(
{ {
**TEMPLATE_SENSOR_BASE_SCHEMA.schema, **TEMPLATE_SENSOR_BASE_SCHEMA.schema,
@ -116,6 +121,62 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo
return True return True
async def async_migrate_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 2:
# Don't migrate from future version
return False
if entry.version == 1:
old_to_new_sensor_id = {}
for sensor in entry.options[SENSOR_DOMAIN]:
# Create a new sub config entry per sensor
sensor_config = dict(sensor)
title = sensor_config.pop(CONF_NAME)
old_unique_id = sensor_config.pop(CONF_UNIQUE_ID)
new_sub_entry = ConfigSubentry(
data=sensor, subentry_type="entity", title=title, unique_id=None
)
old_to_new_sensor_id[old_unique_id] = new_sub_entry.subentry_id
hass.config_entries.async_add_subentry(entry, new_sub_entry)
# Use the new sub config entry id as the unique id for the sensor entity
entity_reg = er.async_get(hass)
entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id)
for entity in entities:
if entity.unique_id in old_to_new_sensor_id:
entity_reg.async_update_entity(
entity.entity_id,
new_unique_id=old_to_new_sensor_id[entity.unique_id],
)
# Use the new sub config entry id as the unique id for the sensor device
device_reg = dr.async_get(hass)
devices = dr.async_entries_for_config_entry(device_reg, entry.entry_id)
for device in devices:
for identifier in device.identifiers:
device_unique_id = identifier[1]
if device_unique_id in old_to_new_sensor_id:
device_reg.async_update_device(
device.id,
add_config_subentry_id=old_to_new_sensor_id[device_unique_id],
new_identifiers={
(DOMAIN, old_to_new_sensor_id[device_unique_id])
},
)
# Remove the sensors as they are now subentries
new_config_entry_options = dict(entry.options)
new_config_entry_options.pop(SENSOR_DOMAIN)
hass.config_entries.async_update_entry(
entry, version=2, options=new_config_entry_options
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Scrape config entry.""" """Unload Scrape config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@ -127,7 +188,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_remove_config_entry_device( async def async_remove_config_entry_device(
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry
) -> bool: ) -> bool:
"""Remove Scrape config entry from a device.""" """Remove Scrape config entry from a device."""
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)

View File

@ -2,21 +2,28 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping import logging
from typing import Any, cast from typing import Any
import uuid
import voluptuous as vol import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.rest import create_rest_data_from_config from homeassistant.components.rest import create_rest_data_from_config
from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.data import DEFAULT_TIMEOUT
from homeassistant.components.rest.schema import DEFAULT_METHOD, METHODS from homeassistant.components.rest.schema import DEFAULT_METHOD, METHODS
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
CONF_STATE_CLASS, CONF_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass, SensorDeviceClass,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
OptionsFlow,
SubentryFlowResult,
)
from homeassistant.const import ( from homeassistant.const import (
CONF_ATTRIBUTE, CONF_ATTRIBUTE,
CONF_AUTHENTICATION, CONF_AUTHENTICATION,
@ -28,7 +35,6 @@ from homeassistant.const import (
CONF_PAYLOAD, CONF_PAYLOAD,
CONF_RESOURCE, CONF_RESOURCE,
CONF_TIMEOUT, CONF_TIMEOUT,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME, CONF_USERNAME,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
@ -37,15 +43,7 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION,
UnitOfTemperature, UnitOfTemperature,
) )
from homeassistant.core import async_get_hass from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
SchemaFlowMenuStep,
)
from homeassistant.helpers.selector import ( from homeassistant.helpers.selector import (
BooleanSelector, BooleanSelector,
NumberSelector, NumberSelector,
@ -73,7 +71,10 @@ from .const import (
DOMAIN, DOMAIN,
) )
RESOURCE_SETUP = { _LOGGER = logging.getLogger(__name__)
RESOURCE_SETUP = vol.Schema(
{
vol.Required(CONF_RESOURCE): TextSelector( vol.Required(CONF_RESOURCE): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL) TextSelectorConfig(type=TextSelectorType.URL)
), ),
@ -81,36 +82,73 @@ RESOURCE_SETUP = {
SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN)
), ),
vol.Optional(CONF_PAYLOAD): ObjectSelector(), vol.Optional(CONF_PAYLOAD): ObjectSelector(),
vol.Required("auth"): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_AUTHENTICATION): SelectSelector( vol.Optional(CONF_AUTHENTICATION): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION], options=[
HTTP_BASIC_AUTHENTICATION,
HTTP_DIGEST_AUTHENTICATION,
],
mode=SelectSelectorMode.DROPDOWN, mode=SelectSelectorMode.DROPDOWN,
) )
), ),
vol.Optional(CONF_USERNAME): TextSelector(), vol.Optional(CONF_USERNAME): TextSelector(
vol.Optional(CONF_PASSWORD): TextSelector( TextSelectorConfig(
TextSelectorConfig(type=TextSelectorType.PASSWORD) type=TextSelectorType.TEXT, autocomplete="username"
)
), ),
vol.Optional(CONF_PASSWORD): TextSelector(
TextSelectorConfig(
type=TextSelectorType.PASSWORD,
autocomplete="current-password",
)
),
}
)
),
vol.Required("advanced"): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_HEADERS): ObjectSelector(), vol.Optional(CONF_HEADERS): ObjectSelector(),
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(), vol.Optional(
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL
): BooleanSelector(),
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
), ),
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), vol.Optional(
CONF_ENCODING, default=DEFAULT_ENCODING
): TextSelector(),
} }
)
),
}
)
SENSOR_SETUP = { SENSOR_SETUP = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
vol.Required(CONF_SELECT): TextSelector(), vol.Required(CONF_SELECT): TextSelector(),
vol.Optional(CONF_INDEX, default=0): NumberSelector( vol.Optional(CONF_INDEX, default=0): vol.All(
NumberSelector(
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
), ),
vol.Coerce(int),
),
vol.Required("advanced"): data_entry_flow.section(
vol.Schema(
{
vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_ATTRIBUTE): TextSelector(),
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
vol.Optional(CONF_AVAILABILITY): TemplateSelector(), vol.Optional(CONF_AVAILABILITY): TemplateSelector(),
vol.Optional(CONF_DEVICE_CLASS): SelectSelector( vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=[ options=[
cls.value for cls in SensorDeviceClass if cls != SensorDeviceClass.ENUM cls.value
for cls in SensorDeviceClass
if cls != SensorDeviceClass.ENUM
], ],
mode=SelectSelectorMode.DROPDOWN, mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class", translation_key="device_class",
@ -135,180 +173,93 @@ SENSOR_SETUP = {
) )
), ),
} }
)
),
}
)
async def validate_rest_setup( async def validate_rest_setup(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] hass: HomeAssistant, user_input: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Validate rest setup.""" """Validate rest setup."""
hass = async_get_hass()
rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input) rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input)
try: try:
rest = create_rest_data_from_config(hass, rest_config) rest = create_rest_data_from_config(hass, rest_config)
await rest.async_update() await rest.async_update()
except Exception as err: except Exception:
raise SchemaFlowError("resource_error") from err _LOGGER.exception("Error when getting resource %s", user_input[CONF_RESOURCE])
return {"base": "resource_error"}
if rest.data is None: if rest.data is None:
raise SchemaFlowError("resource_error") return {"base": "no_data"}
return user_input
async def validate_sensor_setup(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate sensor input."""
user_input[CONF_INDEX] = int(user_input[CONF_INDEX])
user_input[CONF_UNIQUE_ID] = str(uuid.uuid1())
# Standard behavior is to merge the result with the options.
# In this case, we want to add a sub-item so we update the options directly.
sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, [])
sensors.append(user_input)
return {} return {}
async def validate_select_sensor( class ScrapeConfigFlow(ConfigFlow, domain=DOMAIN):
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] """Scrape configuration flow."""
) -> dict[str, Any]:
"""Store sensor index in flow state."""
handler.flow_state["_idx"] = int(user_input[CONF_INDEX])
return {}
VERSION = 2
async def get_select_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: @staticmethod
"""Return schema for selecting a sensor.""" @callback
return vol.Schema( def async_get_options_flow(config_entry: ConfigEntry) -> ScrapeOptionFlow:
{ """Get the options flow for this handler."""
vol.Required(CONF_INDEX): vol.In( return ScrapeOptionFlow()
{
str(index): config[CONF_NAME] @classmethod
for index, config in enumerate(handler.options[SENSOR_DOMAIN]) @callback
}, def async_get_supported_subentry_types(
) cls, config_entry: ConfigEntry
} ) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {"entity": ScrapeSubentryFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""User flow to create a sensor subentry."""
if user_input is not None:
errors = await validate_rest_setup(self.hass, user_input)
title = user_input[CONF_RESOURCE]
if not errors:
return self.async_create_entry(data=user_input, title=title)
return self.async_show_form(
step_id="user", data_schema=RESOURCE_SETUP, errors=errors
) )
async def get_edit_sensor_suggested_values( class ScrapeOptionFlow(OptionsFlow):
handler: SchemaCommonFlowHandler, """Scrape Options flow."""
) -> dict[str, Any]:
"""Return suggested values for sensor editing."""
idx: int = handler.flow_state["_idx"]
return dict(handler.options[SENSOR_DOMAIN][idx])
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage Scrape options."""
async def validate_sensor_edit( if user_input is not None:
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] errors = await validate_rest_setup(self.hass, user_input)
) -> dict[str, Any]: if not errors:
"""Update edited sensor.""" return self.async_create_entry(data=user_input)
user_input[CONF_INDEX] = int(user_input[CONF_INDEX])
# Standard behavior is to merge the result with the options. return self.async_show_form(
# In this case, we want to add a sub-item so we update the options directly, step_id="init",
# including popping omitted optional schema items. data_schema=self.add_suggested_values_to_schema(
idx: int = handler.flow_state["_idx"] RESOURCE_SETUP,
handler.options[SENSOR_DOMAIN][idx].update(user_input) self.config_entry.options,
for key in DATA_SCHEMA_EDIT_SENSOR.schema: ),
if isinstance(key, vol.Optional) and key not in user_input:
# Key not present, delete keys old value (if present) too
handler.options[SENSOR_DOMAIN][idx].pop(key, None)
return {}
async def get_remove_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Return schema for sensor removal."""
return vol.Schema(
{
vol.Required(CONF_INDEX): cv.multi_select(
{
str(index): config[CONF_NAME]
for index, config in enumerate(handler.options[SENSOR_DOMAIN])
},
)
}
) )
async def validate_remove_sensor( class ScrapeSubentryFlowHandler(ConfigSubentryFlow):
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] """Handle subentry flow."""
) -> dict[str, Any]:
"""Validate remove sensor."""
removed_indexes: set[str] = set(user_input[CONF_INDEX])
# Standard behavior is to merge the result with the options. async def async_step_user(
# In this case, we want to remove sub-items so we update the options directly. self, user_input: dict[str, Any] | None = None
entity_registry = er.async_get(handler.parent_handler.hass) ) -> SubentryFlowResult:
sensors: list[dict[str, Any]] = [] """User flow to create a sensor subentry."""
sensor: dict[str, Any] if user_input is not None:
for index, sensor in enumerate(handler.options[SENSOR_DOMAIN]): title = user_input.pop("name")
if str(index) not in removed_indexes: return self.async_create_entry(data=user_input, title=title)
sensors.append(sensor)
elif entity_id := entity_registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, sensor[CONF_UNIQUE_ID]
):
entity_registry.async_remove(entity_id)
handler.options[SENSOR_DOMAIN] = sensors
return {}
return self.async_show_form(step_id="user", data_schema=SENSOR_SETUP)
DATA_SCHEMA_RESOURCE = vol.Schema(RESOURCE_SETUP)
DATA_SCHEMA_EDIT_SENSOR = vol.Schema(SENSOR_SETUP)
DATA_SCHEMA_SENSOR = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
**SENSOR_SETUP,
}
)
CONFIG_FLOW = {
"user": SchemaFlowFormStep(
schema=DATA_SCHEMA_RESOURCE,
next_step="sensor",
validate_user_input=validate_rest_setup,
),
"sensor": SchemaFlowFormStep(
schema=DATA_SCHEMA_SENSOR,
validate_user_input=validate_sensor_setup,
),
}
OPTIONS_FLOW = {
"init": SchemaFlowMenuStep(
["resource", "add_sensor", "select_edit_sensor", "remove_sensor"]
),
"resource": SchemaFlowFormStep(
DATA_SCHEMA_RESOURCE,
validate_user_input=validate_rest_setup,
),
"add_sensor": SchemaFlowFormStep(
DATA_SCHEMA_SENSOR,
suggested_values=None,
validate_user_input=validate_sensor_setup,
),
"select_edit_sensor": SchemaFlowFormStep(
get_select_sensor_schema,
suggested_values=None,
validate_user_input=validate_select_sensor,
next_step="edit_sensor",
),
"edit_sensor": SchemaFlowFormStep(
DATA_SCHEMA_EDIT_SENSOR,
suggested_values=get_edit_sensor_suggested_values,
validate_user_input=validate_sensor_edit,
),
"remove_sensor": SchemaFlowFormStep(
get_remove_sensor_schema,
suggested_values=None,
validate_user_input=validate_remove_sensor,
),
}
class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for Scrape."""
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return cast(str, options[CONF_RESOURCE])

View File

@ -101,7 +101,8 @@ async def async_setup_entry(
entities: list = [] entities: list = []
coordinator = entry.runtime_data coordinator = entry.runtime_data
config = dict(entry.options) for subentry in entry.subentries.values():
config = dict(subentry.data)
for sensor in config["sensor"]: for sensor in config["sensor"]:
sensor_config: ConfigType = vol.Schema( sensor_config: ConfigType = vol.Schema(
TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA