diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 68a8cf62fe4..3e96ede72e2 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -5,26 +5,29 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine from datetime import timedelta +import logging from typing import Any import voluptuous as vol from homeassistant.components.rest import RESOURCE_SCHEMA, create_rest_data_from_config 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 ( CONF_ATTRIBUTE, + CONF_NAME, CONF_SCAN_INTERVAL, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, discovery, entity_registry as er, ) -from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, @@ -36,6 +39,8 @@ from .coordinator import ScrapeCoordinator type ScrapeConfigEntry = ConfigEntry[ScrapeCoordinator] +_LOGGER = logging.getLogger(__name__) + SENSOR_SCHEMA = vol.Schema( { **TEMPLATE_SENSOR_BASE_SCHEMA.schema, @@ -116,6 +121,62 @@ async def async_setup_entry(hass: HomeAssistant, entry: ScrapeConfigEntry) -> bo 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: """Unload Scrape config entry.""" 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( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: ConfigEntry, device: dr.DeviceEntry ) -> bool: """Remove Scrape config entry from a device.""" entity_registry = er.async_get(hass) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 017b3c707a9..65d70f5fce8 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -2,21 +2,28 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Any, cast -import uuid +import logging +from typing import Any import voluptuous as vol +from homeassistant import data_entry_flow from homeassistant.components.rest import create_rest_data_from_config from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD, METHODS from homeassistant.components.sensor import ( CONF_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + OptionsFlow, + SubentryFlowResult, +) from homeassistant.const import ( CONF_ATTRIBUTE, CONF_AUTHENTICATION, @@ -28,7 +35,6 @@ from homeassistant.const import ( CONF_PAYLOAD, CONF_RESOURCE, CONF_TIMEOUT, - CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -37,15 +43,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, UnitOfTemperature, ) -from homeassistant.core import async_get_hass -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.core import HomeAssistant, callback from homeassistant.helpers.selector import ( BooleanSelector, NumberSelector, @@ -73,242 +71,195 @@ from .const import ( DOMAIN, ) -RESOURCE_SETUP = { - vol.Required(CONF_RESOURCE): TextSelector( - TextSelectorConfig(type=TextSelectorType.URL) - ), - vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector( - SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) - ), - vol.Optional(CONF_PAYLOAD): ObjectSelector(), - vol.Optional(CONF_AUTHENTICATION): SelectSelector( - SelectSelectorConfig( - options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION], - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional(CONF_USERNAME): TextSelector(), - vol.Optional(CONF_PASSWORD): TextSelector( - TextSelectorConfig(type=TextSelectorType.PASSWORD) - ), - vol.Optional(CONF_HEADERS): ObjectSelector(), - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(), - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ), - vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), -} +_LOGGER = logging.getLogger(__name__) -SENSOR_SETUP = { - vol.Required(CONF_SELECT): TextSelector(), - vol.Optional(CONF_INDEX, default=0): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) - ), - vol.Optional(CONF_ATTRIBUTE): TextSelector(), - vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_AVAILABILITY): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( - SelectSelectorConfig( - options=[ - cls.value for cls in SensorDeviceClass if cls != SensorDeviceClass.ENUM - ], - mode=SelectSelectorMode.DROPDOWN, - translation_key="device_class", - sort=True, - ) - ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( - SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], - mode=SelectSelectorMode.DROPDOWN, - translation_key="state_class", - sort=True, - ) - ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( - SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], - custom_value=True, - mode=SelectSelectorMode.DROPDOWN, - translation_key="unit_of_measurement", - sort=True, - ) - ), -} +RESOURCE_SETUP = vol.Schema( + { + vol.Required(CONF_RESOURCE): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector( + SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) + ), + vol.Optional(CONF_PAYLOAD): ObjectSelector(), + vol.Required("auth"): data_entry_flow.section( + vol.Schema( + { + vol.Optional(CONF_AUTHENTICATION): SelectSelector( + SelectSelectorConfig( + options=[ + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_USERNAME): TextSelector( + TextSelectorConfig( + 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_VERIFY_SSL, default=DEFAULT_VERIFY_SSL + ): BooleanSelector(), + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional( + CONF_ENCODING, default=DEFAULT_ENCODING + ): TextSelector(), + } + ) + ), + } +) + +SENSOR_SETUP = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_SELECT): TextSelector(), + vol.Optional(CONF_INDEX, default=0): vol.All( + NumberSelector( + 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_VALUE_TEMPLATE): TemplateSelector(), + vol.Optional(CONF_AVAILABILITY): TemplateSelector(), + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class", + sort=True, + ) + ), + vol.Optional(CONF_STATE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", + sort=True, + ) + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in UnitOfTemperature], + custom_value=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="unit_of_measurement", + sort=True, + ) + ), + } + ) + ), + } +) async def validate_rest_setup( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] + hass: HomeAssistant, user_input: dict[str, Any] ) -> dict[str, Any]: """Validate rest setup.""" - hass = async_get_hass() rest_config: dict[str, Any] = COMBINED_SCHEMA(user_input) try: rest = create_rest_data_from_config(hass, rest_config) await rest.async_update() - except Exception as err: - raise SchemaFlowError("resource_error") from err + except Exception: + _LOGGER.exception("Error when getting resource %s", user_input[CONF_RESOURCE]) + return {"base": "resource_error"} if rest.data is None: - raise SchemaFlowError("resource_error") - 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 {"base": "no_data"} return {} -async def validate_select_sensor( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - """Store sensor index in flow state.""" - handler.flow_state["_idx"] = int(user_input[CONF_INDEX]) - return {} +class ScrapeConfigFlow(ConfigFlow, domain=DOMAIN): + """Scrape configuration flow.""" + + VERSION = 2 + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> ScrapeOptionFlow: + """Get the options flow for this handler.""" + return ScrapeOptionFlow() + + @classmethod + @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_select_sensor_schema(handler: SchemaCommonFlowHandler) -> vol.Schema: - """Return schema for selecting a sensor.""" - return vol.Schema( - { - vol.Required(CONF_INDEX): vol.In( - { - str(index): config[CONF_NAME] - for index, config in enumerate(handler.options[SENSOR_DOMAIN]) - }, - ) - } - ) +class ScrapeOptionFlow(OptionsFlow): + """Scrape Options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage Scrape options.""" + + if user_input is not None: + errors = await validate_rest_setup(self.hass, user_input) + if not errors: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + RESOURCE_SETUP, + self.config_entry.options, + ), + ) -async def get_edit_sensor_suggested_values( - handler: SchemaCommonFlowHandler, -) -> dict[str, Any]: - """Return suggested values for sensor editing.""" - idx: int = handler.flow_state["_idx"] - return dict(handler.options[SENSOR_DOMAIN][idx]) +class ScrapeSubentryFlowHandler(ConfigSubentryFlow): + """Handle subentry flow.""" + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + if user_input is not None: + title = user_input.pop("name") + return self.async_create_entry(data=user_input, title=title) -async def validate_sensor_edit( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> dict[str, Any]: - """Update edited sensor.""" - user_input[CONF_INDEX] = int(user_input[CONF_INDEX]) - - # 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, - # including popping omitted optional schema items. - idx: int = handler.flow_state["_idx"] - handler.options[SENSOR_DOMAIN][idx].update(user_input) - 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( - handler: SchemaCommonFlowHandler, user_input: dict[str, Any] -) -> 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. - # In this case, we want to remove sub-items so we update the options directly. - entity_registry = er.async_get(handler.parent_handler.hass) - sensors: list[dict[str, Any]] = [] - sensor: dict[str, Any] - for index, sensor in enumerate(handler.options[SENSOR_DOMAIN]): - if str(index) not in removed_indexes: - 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 {} - - -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]) + return self.async_show_form(step_id="user", data_schema=SENSOR_SETUP) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index b8ad9cb8a56..22e7d1df8f0 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -101,40 +101,41 @@ async def async_setup_entry( entities: list = [] coordinator = entry.runtime_data - config = dict(entry.options) - for sensor in config["sensor"]: - sensor_config: ConfigType = vol.Schema( - TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA - )(sensor) + for subentry in entry.subentries.values(): + config = dict(subentry.data) + for sensor in config["sensor"]: + sensor_config: ConfigType = vol.Schema( + TEMPLATE_SENSOR_BASE_SCHEMA.schema, extra=vol.ALLOW_EXTRA + )(sensor) - name: str = sensor_config[CONF_NAME] - value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE) + name: str = sensor_config[CONF_NAME] + value_string: str | None = sensor_config.get(CONF_VALUE_TEMPLATE) - value_template: Template | None = ( - Template(value_string, hass) if value_string is not None else None - ) - - trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name} - for key in TRIGGER_ENTITY_OPTIONS: - if key not in sensor_config: - continue - if key == CONF_AVAILABILITY: - trigger_entity_config[key] = Template(sensor_config[key], hass) - continue - trigger_entity_config[key] = sensor_config[key] - - entities.append( - ScrapeSensor( - hass, - coordinator, - trigger_entity_config, - sensor_config[CONF_SELECT], - sensor_config.get(CONF_ATTRIBUTE), - sensor_config[CONF_INDEX], - value_template, - False, + value_template: Template | None = ( + Template(value_string, hass) if value_string is not None else None + ) + + trigger_entity_config: dict[str, str | Template | None] = {CONF_NAME: name} + for key in TRIGGER_ENTITY_OPTIONS: + if key not in sensor_config: + continue + if key == CONF_AVAILABILITY: + trigger_entity_config[key] = Template(sensor_config[key], hass) + continue + trigger_entity_config[key] = sensor_config[key] + + entities.append( + ScrapeSensor( + hass, + coordinator, + trigger_entity_config, + sensor_config[CONF_SELECT], + sensor_config.get(CONF_ATTRIBUTE), + sensor_config[CONF_INDEX], + value_template, + False, + ) ) - ) async_add_entities(entities)