diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 5bc865dbb6c..cb5657a9649 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -10,6 +10,7 @@ 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.const import ( CONF_ATTRIBUTE, CONF_SCAN_INTERVAL, @@ -22,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType -from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import CONF_INDEX, CONF_SELECT, DEFAULT_SCAN_INTERVAL, DOMAIN, PLATFORMS from .coordinator import ScrapeCoordinator SENSOR_SCHEMA = vol.Schema( @@ -79,3 +80,37 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await asyncio.gather(*load_coroutines) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Scrape from a config entry.""" + + rest_config: dict[str, Any] = COMBINED_SCHEMA(dict(entry.options)) + rest = create_rest_data_from_config(hass, rest_config) + + coordinator = ScrapeCoordinator( + hass, + rest, + DEFAULT_SCAN_INTERVAL, + ) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Scrape config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN][entry.entry_id] + if not hass.data[DOMAIN]: + del hass.data[DOMAIN] + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index a32e371a487..034667a4a76 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -6,6 +6,9 @@ from typing import Any import voluptuous as vol +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, SensorDeviceClass, @@ -16,18 +19,23 @@ from homeassistant.const import ( CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_HEADERS, + CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, + CONF_TIMEOUT, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, + UnitOfTemperature, ) +from homeassistant.core import async_get_hass from homeassistant.helpers.schema_config_entry_flow import ( SchemaConfigFlowHandler, + SchemaFlowError, SchemaFlowFormStep, SchemaFlowMenuStep, SchemaOptionsFlowHandler, @@ -47,20 +55,15 @@ from homeassistant.helpers.selector import ( TextSelectorType, ) +from . import COMBINED_SCHEMA from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN -SCHEMA_SETUP = { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), +RESOURCE_SETUP = { vol.Required(CONF_RESOURCE): TextSelector( TextSelectorConfig(type=TextSelectorType.URL) ), - vol.Required(CONF_SELECT): TextSelector(), -} - -SCHEMA_OPT = { - vol.Optional(CONF_ATTRIBUTE): TextSelector(), - vol.Optional(CONF_INDEX, default=0): NumberSelector( - NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): SelectSelector( + SelectSelectorConfig(options=METHODS, mode=SelectSelectorMode.DROPDOWN) ), vol.Optional(CONF_AUTHENTICATION): SelectSelector( SelectSelectorConfig( @@ -73,32 +76,74 @@ SCHEMA_OPT = { TextSelectorConfig(type=TextSelectorType.PASSWORD) ), vol.Optional(CONF_HEADERS): ObjectSelector(), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): TextSelector(), + 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) + ), +} + +SENSOR_SETUP = { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + 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_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[e.value for e in SensorDeviceClass], + options=[cls.value for cls in SensorDeviceClass], mode=SelectSelectorMode.DROPDOWN, ) ), vol.Optional(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[e.value for e in SensorStateClass], + options=[cls.value for cls in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in UnitOfTemperature], + custom_value=True, mode=SelectSelectorMode.DROPDOWN, ) ), - vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(), } -DATA_SCHEMA = vol.Schema({**SCHEMA_SETUP, **SCHEMA_OPT}) -DATA_SCHEMA_OPT = vol.Schema({**SCHEMA_OPT}) + +def validate_rest_setup(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: + create_rest_data_from_config(hass, rest_config) + except Exception as err: + raise SchemaFlowError("resource_error") from err + return user_input + + +def validate_sensor_setup(user_input: dict[str, Any]) -> dict[str, Any]: + """Validate sensor setup.""" + return {"sensor": [{**user_input, CONF_INDEX: int(user_input[CONF_INDEX])}]} + + +DATA_SCHEMA_RESOURCE = vol.Schema(RESOURCE_SETUP) +DATA_SCHEMA_SENSOR = vol.Schema(SENSOR_SETUP) CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { - "user": SchemaFlowFormStep(DATA_SCHEMA), - "import": SchemaFlowFormStep(DATA_SCHEMA), + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA_RESOURCE, + next_step=lambda _: "sensor", + validate_user_input=validate_rest_setup, + ), + "sensor": SchemaFlowFormStep( + schema=DATA_SCHEMA_SENSOR, + validate_user_input=validate_sensor_setup, + ), } OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { - "init": SchemaFlowFormStep(DATA_SCHEMA_OPT), + "init": SchemaFlowFormStep(DATA_SCHEMA_RESOURCE), } @@ -110,12 +155,7 @@ class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" - return options[CONF_NAME] - - def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: - """Check for duplicate records.""" - data: dict[str, Any] = dict(options) - self._async_abort_entries_match(data) + return options[CONF_RESOURCE] class ScrapeOptionsFlowHandler(SchemaOptionsFlowHandler): diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index b7e5660e381..86319ce3744 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -5,5 +5,6 @@ "requirements": ["beautifulsoup4==4.11.1", "lxml==4.9.1"], "after_dependencies": ["rest"], "codeowners": ["@fabaff", "@gjohansson-ST", "@epenet"], - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "config_flow": true } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 61f0b9711b1..10b96716929 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ATTRIBUTE, CONF_AUTHENTICATION, @@ -144,6 +145,45 @@ async def async_setup_platform( async_add_entities(entities) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Scrape sensor entry.""" + entities: list = [] + + coordinator: ScrapeCoordinator = hass.data[DOMAIN][entry.entry_id] + config = dict(entry.options) + 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] + select: str = sensor_config[CONF_SELECT] + attr: str | None = sensor_config.get(CONF_ATTRIBUTE) + index: int = int(sensor_config[CONF_INDEX]) + 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 + ) + entities.append( + ScrapeSensor( + hass, + coordinator, + sensor_config, + name, + None, + select, + attr, + index, + value_template, + ) + ) + + async_add_entities(entities) + + class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], TemplateSensor): """Representation of a web scrape sensor.""" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 11c17a176a3..391bfe5ad9f 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -3,35 +3,48 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" }, + "error": { + "resource_error": "Could not update rest data. Verify your configuration" + }, "step": { "user": { "data": { - "name": "[%key:common::config_flow::data::name%]", "resource": "Resource", - "select": "Select", - "attribute": "Attribute", - "index": "Index", - "value_template": "Value Template", - "unit_of_measurement": "Unit of Measurement", - "device_class": "Device Class", - "state_class": "State Class", - "authentication": "Authentication", + "authentication": "Select authentication method", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "headers": "Headers" + "headers": "Headers", + "method": "Method", + "timeout": "Timeout" }, "data_description": { "resource": "The URL to the website that contains the value", + "authentication": "Type of the HTTP authentication. Either basic or digest", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", + "headers": "Headers to use for the web request", + "timeout": "Timeout for connection to website" + } + }, + "sensor": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "attribute": "Attribute", + "index": "Index", + "select": "Select", + "value_template": "Value Template", + "device_class": "Device Class", + "state_class": "State Class", + "unit_of_measurement": "Unit of Measurement" + }, + "data_description": { "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", "attribute": "Get value of an attribute on the selected tag", "index": "Defines which of the elements returned by the CSS selector to use", "value_template": "Defines a template to get the state of the sensor", "device_class": "The type/class of the sensor to set the icon in the frontend", "state_class": "The state_class of the sensor", - "authentication": "Type of the HTTP authentication. Either basic or digest", - "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", - "headers": "Headers to use for the web request" + "unit_of_measurement": "Choose temperature measurement or create your own" } } } @@ -40,32 +53,21 @@ "step": { "init": { "data": { - "name": "[%key:component::scrape::config::step::user::data::name%]", "resource": "[%key:component::scrape::config::step::user::data::resource%]", - "select": "[%key:component::scrape::config::step::user::data::select%]", - "attribute": "[%key:component::scrape::config::step::user::data::attribute%]", - "index": "[%key:component::scrape::config::step::user::data::index%]", - "value_template": "[%key:component::scrape::config::step::user::data::value_template%]", - "unit_of_measurement": "[%key:component::scrape::config::step::user::data::unit_of_measurement%]", - "device_class": "[%key:component::scrape::config::step::user::data::device_class%]", - "state_class": "[%key:component::scrape::config::step::user::data::state_class%]", + "method": "[%key:component::scrape::config::step::user::data::method%]", "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", - "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", "username": "[%key:component::scrape::config::step::user::data::username%]", "password": "[%key:component::scrape::config::step::user::data::password%]", - "headers": "[%key:component::scrape::config::step::user::data::headers%]" + "headers": "[%key:component::scrape::config::step::user::data::headers%]", + "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", + "timeout": "[%key:component::scrape::config::step::user::data::timeout%]" }, "data_description": { "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", - "select": "[%key:component::scrape::config::step::user::data_description::select%]", - "attribute": "[%key:component::scrape::config::step::user::data_description::attribute%]", - "index": "[%key:component::scrape::config::step::user::data_description::index%]", - "value_template": "[%key:component::scrape::config::step::user::data_description::value_template%]", - "device_class": "[%key:component::scrape::config::step::user::data_description::device_class%]", - "state_class": "[%key:component::scrape::config::step::user::data_description::state_class%]", "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", + "headers": "[%key:component::scrape::config::step::user::data_description::headers%]", "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", - "headers": "[%key:component::scrape::config::step::user::data_description::headers%]" + "timeout": "[%key:component::scrape::config::step::user::data_description::timeout%]" } } } diff --git a/homeassistant/components/scrape/translations/en.json b/homeassistant/components/scrape/translations/en.json index b64310f0251..8cc642c12a2 100644 --- a/homeassistant/components/scrape/translations/en.json +++ b/homeassistant/components/scrape/translations/en.json @@ -3,34 +3,47 @@ "abort": { "already_configured": "Account is already configured" }, + "error": { + "resource_error": "Could not update rest data. Verify your configuration" + }, "step": { - "user": { + "sensor": { "data": { "attribute": "Attribute", - "authentication": "Authentication", "device_class": "Device Class", - "headers": "Headers", "index": "Index", "name": "Name", - "password": "Password", - "resource": "Resource", "select": "Select", "state_class": "State Class", "unit_of_measurement": "Unit of Measurement", - "username": "Username", - "value_template": "Value Template", - "verify_ssl": "Verify SSL certificate" + "value_template": "Value Template" }, "data_description": { "attribute": "Get value of an attribute on the selected tag", - "authentication": "Type of the HTTP authentication. Either basic or digest", "device_class": "The type/class of the sensor to set the icon in the frontend", - "headers": "Headers to use for the web request", "index": "Defines which of the elements returned by the CSS selector to use", - "resource": "The URL to the website that contains the value", "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", "state_class": "The state_class of the sensor", - "value_template": "Defines a template to get the state of the sensor", + "unit_of_measurement": "Choose temperature measurement or create your own", + "value_template": "Defines a template to get the state of the sensor" + } + }, + "user": { + "data": { + "authentication": "Select authentication method", + "headers": "Headers", + "method": "Method", + "password": "Password", + "resource": "Resource", + "timeout": "Timeout", + "username": "Username", + "verify_ssl": "Verify SSL certificate" + }, + "data_description": { + "authentication": "Type of the HTTP authentication. Either basic or digest", + "headers": "Headers to use for the web request", + "resource": "The URL to the website that contains the value", + "timeout": "Timeout for connection to website", "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" } } @@ -46,31 +59,20 @@ "step": { "init": { "data": { - "attribute": "Attribute", - "authentication": "Authentication", - "device_class": "Device Class", + "authentication": "Select authentication method", "headers": "Headers", - "index": "Index", - "name": "Name", + "method": "Method", "password": "Password", "resource": "Resource", - "select": "Select", - "state_class": "State Class", - "unit_of_measurement": "Unit of Measurement", + "timeout": "Timeout", "username": "Username", - "value_template": "Value Template", "verify_ssl": "Verify SSL certificate" }, "data_description": { - "attribute": "Get value of an attribute on the selected tag", "authentication": "Type of the HTTP authentication. Either basic or digest", - "device_class": "The type/class of the sensor to set the icon in the frontend", "headers": "Headers to use for the web request", - "index": "Defines which of the elements returned by the CSS selector to use", "resource": "The URL to the website that contains the value", - "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", - "state_class": "The state_class of the sensor", - "value_template": "Defines a template to get the state of the sensor", + "timeout": "Timeout for connection to website", "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e3519ac8773..33579c6b5ee 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -347,6 +347,7 @@ FLOWS = { "ruuvitag_ble", "sabnzbd", "samsungtv", + "scrape", "screenlogic", "season", "sense", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 69911965eb4..0eb5ed5fc6f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4597,7 +4597,7 @@ "scrape": { "name": "Scrape", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "screenaway": { diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index d4eac856d7d..b13cc4a7326 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -98,11 +98,20 @@ class MockRestData: self.count += 1 if self.payload == "test_scrape_sensor": self.data = ( + # Default "
" "Trying to get" ) + if self.payload == "test_scrape_sensor2": + self.data = ( + # Hidden version + "" + "Trying to get" + ) if self.payload == "test_scrape_uom_and_classes": self.data = ( "