diff --git a/CODEOWNERS b/CODEOWNERS index fe8ce9a46ad..e2e9fc27b5c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -887,8 +887,8 @@ build.json @home-assistant/supervisor /homeassistant/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core /homeassistant/components/schluter/ @prairieapps -/homeassistant/components/scrape/ @fabaff -/tests/components/scrape/ @fabaff +/homeassistant/components/scrape/ @fabaff @gjohansson-ST +/tests/components/scrape/ @fabaff @gjohansson-ST /homeassistant/components/screenlogic/ @dieselrabbit @bdraco /tests/components/screenlogic/ @dieselrabbit @bdraco /homeassistant/components/script/ @home-assistant/core diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index f9222c126b5..684be76b80d 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -1 +1,64 @@ """The scrape component.""" +from __future__ import annotations + +import httpx + +from homeassistant.components.rest.data import RestData +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_HEADERS, + CONF_PASSWORD, + CONF_RESOURCE, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Scrape from a config entry.""" + + resource: str = entry.options[CONF_RESOURCE] + method: str = "GET" + payload: str | None = None + headers: str | None = entry.options.get(CONF_HEADERS) + verify_ssl: bool = entry.options[CONF_VERIFY_SSL] + username: str | None = entry.options.get(CONF_USERNAME) + password: str | None = entry.options.get(CONF_PASSWORD) + + auth: httpx.DigestAuth | tuple[str, str] | None = None + if username and password: + if entry.options.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: + auth = httpx.DigestAuth(username, password) + else: + auth = (username, password) + + rest = RestData(hass, method, resource, auth, headers, None, payload, verify_ssl) + await rest.async_update() + + if rest.data is None: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = rest + + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener for options.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Scrape config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py new file mode 100644 index 00000000000..a32e371a487 --- /dev/null +++ b/homeassistant/components/scrape/config_flow.py @@ -0,0 +1,122 @@ +"""Adds config flow for Scrape integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + CONF_ATTRIBUTE, + CONF_AUTHENTICATION, + CONF_DEVICE_CLASS, + CONF_HEADERS, + CONF_NAME, + CONF_PASSWORD, + CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, + SchemaOptionsFlowHandler, +) +from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + ObjectSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN + +SCHEMA_SETUP = { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + 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_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_UNIT_OF_MEASUREMENT): TextSelector(), + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[e.value for e in SensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_STATE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[e.value for e in SensorStateClass], + 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}) + +CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "user": SchemaFlowFormStep(DATA_SCHEMA), + "import": SchemaFlowFormStep(DATA_SCHEMA), +} +OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep(DATA_SCHEMA_OPT), +} + + +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 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) + + +class ScrapeOptionsFlowHandler(SchemaOptionsFlowHandler): + """Handle a config flow for Scrape.""" diff --git a/homeassistant/components/scrape/const.py b/homeassistant/components/scrape/const.py new file mode 100644 index 00000000000..88eb661d29a --- /dev/null +++ b/homeassistant/components/scrape/const.py @@ -0,0 +1,13 @@ +"""Constants for Scrape integration.""" +from __future__ import annotations + +from homeassistant.const import Platform + +DOMAIN = "scrape" +DEFAULT_NAME = "Web scrape" +DEFAULT_VERIFY_SSL = True + +PLATFORMS = [Platform.SENSOR] + +CONF_SELECT = "select" +CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index b1ccbb354a9..631af2e6051 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -4,6 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/scrape", "requirements": ["beautifulsoup4==4.11.1", "lxml==4.8.0"], "after_dependencies": ["rest"], - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@gjohansson-ST"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index e15f7c5ba97..4fc08cba571 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -5,7 +5,6 @@ import logging from typing import Any from bs4 import BeautifulSoup -import httpx import voluptuous as vol from homeassistant.components.rest.data import RestData @@ -16,7 +15,9 @@ from homeassistant.components.sensor import ( STATE_CLASSES_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_ATTRIBUTE, CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_HEADERS, @@ -31,26 +32,24 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_ATTR = "attribute" -CONF_SELECT = "select" -CONF_INDEX = "index" - -DEFAULT_NAME = "Web scrape" -DEFAULT_VERIFY_SSL = True +ICON = "mdi:web" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_RESOURCE): cv.string, vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_ATTR): cv.string, + vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Optional(CONF_INDEX, default=0): cv.positive_int, vol.Optional(CONF_AUTHENTICATION): vol.In( [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] @@ -62,7 +61,7 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, } ) @@ -75,37 +74,47 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Web scrape sensor.""" - name: str = config[CONF_NAME] - resource: str = config[CONF_RESOURCE] - method: str = "GET" - payload: str | None = None - headers: str | None = config.get(CONF_HEADERS) - verify_ssl: bool = config[CONF_VERIFY_SSL] - select: str | None = config.get(CONF_SELECT) - attr: str | None = config.get(CONF_ATTR) - index: int = config[CONF_INDEX] - unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) - device_class: str | None = config.get(CONF_DEVICE_CLASS) - state_class: str | None = config.get(CONF_STATE_CLASS) - username: str | None = config.get(CONF_USERNAME) - password: str | None = config.get(CONF_PASSWORD) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + _LOGGER.warning( + # Config flow added in Home Assistant Core 2022.7, remove import flow in 2022.9 + "Loading Scrape via platform setup has been deprecated in Home Assistant 2022.7 " + "Your configuration has been automatically imported and you can " + "remove it from your configuration.yaml" + ) + if config.get(CONF_VALUE_TEMPLATE): + template: Template = Template(config[CONF_VALUE_TEMPLATE]) + template.ensure_valid() + config[CONF_VALUE_TEMPLATE] = template.template + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Scrape sensor entry.""" + name: str = entry.options[CONF_NAME] + resource: str = entry.options[CONF_RESOURCE] + select: str | None = entry.options.get(CONF_SELECT) + attr: str | None = entry.options.get(CONF_ATTRIBUTE) + index: int = int(entry.options[CONF_INDEX]) + unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) + device_class: str | None = entry.options.get(CONF_DEVICE_CLASS) + state_class: str | None = entry.options.get(CONF_STATE_CLASS) + value_template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) + entry_id: str = entry.entry_id + + val_template: Template | None = None if value_template is not None: - value_template.hass = hass + val_template = Template(value_template, hass) - auth: httpx.DigestAuth | tuple[str, str] | None = None - if username and password: - if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = httpx.DigestAuth(username, password) - else: - auth = (username, password) - - rest = RestData(hass, method, resource, auth, headers, None, payload, verify_ssl) - await rest.async_update() - - if rest.data is None: - raise PlatformNotReady + rest = hass.data.setdefault(DOMAIN, {})[entry.entry_id] async_add_entities( [ @@ -115,10 +124,12 @@ async def async_setup_platform( select, attr, index, - value_template, + val_template, unit, device_class, state_class, + entry_id, + resource, ) ], True, @@ -128,6 +139,8 @@ async def async_setup_platform( class ScrapeSensor(SensorEntity): """Representation of a web scrape sensor.""" + _attr_icon = ICON + def __init__( self, rest: RestData, @@ -139,6 +152,8 @@ class ScrapeSensor(SensorEntity): unit: str | None, device_class: str | None, state_class: str | None, + entry_id: str, + resource: str, ) -> None: """Initialize a web scrape sensor.""" self.rest = rest @@ -151,6 +166,14 @@ class ScrapeSensor(SensorEntity): self._attr_native_unit_of_measurement = unit self._attr_device_class = device_class self._attr_state_class = state_class + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Scrape", + name=name, + configuration_url=resource, + ) def _extract_value(self) -> Any: """Parse the html extraction in the executor.""" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json new file mode 100644 index 00000000000..f328423f5b6 --- /dev/null +++ b/homeassistant/components/scrape/strings.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "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", + "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" + }, + "data_description": { + "resource": "The URL to the website that contains the value", + "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" + } + } + } + }, + "options": { + "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%]", + "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%]" + }, + "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%]", + "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", + "headers": "[%key:component::scrape::config::step::user::data_description::headers%]" + } + } + } + } +} diff --git a/homeassistant/components/scrape/translations/en.json b/homeassistant/components/scrape/translations/en.json new file mode 100644 index 00000000000..20831f5251a --- /dev/null +++ b/homeassistant/components/scrape/translations/en.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "step": { + "user": { + "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" + }, + "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", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" + } + } + } + }, + "options": { + "step": { + "init": { + "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" + }, + "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", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e9ba5971e07..e1e2938c9ff 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -298,6 +298,7 @@ FLOWS = { "ruckus_unleashed", "sabnzbd", "samsungtv", + "scrape", "screenlogic", "season", "sense", diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index 0ba9266a79d..37abb061e75 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -2,6 +2,42 @@ from __future__ import annotations from typing import Any +from unittest.mock import patch + +from homeassistant.components.scrape.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, + config: dict[str, Any], + data: str, + entry_id: str = "1", + source: str = SOURCE_USER, +) -> MockConfigEntry: + """Set up the Scrape integration in Home Assistant.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + source=source, + data={}, + options=config, + entry_id=entry_id, + ) + + config_entry.add_to_hass(hass) + mocker = MockRestData(data) + with patch( + "homeassistant.components.scrape.RestData", + return_value=mocker, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry def return_config( @@ -25,6 +61,8 @@ def return_config( "resource": "https://www.home-assistant.io", "select": select, "name": name, + "index": 0, + "verify_ssl": True, } if attribute: config["attribute"] = attribute @@ -38,7 +76,7 @@ def return_config( config["device_class"] = device_class if state_class: config["state_class"] = state_class - if authentication: + if username: config["authentication"] = authentication config["username"] = username config["password"] = password diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py new file mode 100644 index 00000000000..287004b1dd3 --- /dev/null +++ b/tests/components/scrape/test_config_flow.py @@ -0,0 +1,194 @@ +"""Test the Scrape config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.scrape.const import CONF_INDEX, CONF_SELECT, DOMAIN +from homeassistant.const import ( + CONF_NAME, + CONF_RESOURCE, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import MockRestData + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=MockRestData("test_scrape_sensor"), + ), patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_NAME: "Release", + CONF_SELECT: ".current-version h1", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Release" + assert result2["options"] == { + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0.0, + "verify_ssl": True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=MockRestData("test_scrape_sensor"), + ), patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_NAME: "Release", + CONF_SELECT: ".current-version h1", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Release" + assert result2["options"] == { + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0, + "verify_ssl": True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0, + "verify_ssl": True, + }, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=MockRestData("test_scrape_sensor"), + ), patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_NAME: "Release", + CONF_SELECT: ".current-version h1", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_configured" + + +async def test_options_form(hass: HomeAssistant) -> None: + """Test we get the form in options.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0, + "verify_ssl": True, + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor"), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor"), + ): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "value_template": "{{ value.split(':')[1] }}", + "index": 1.0, + "verify_ssl": True, + }, + ) + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 1.0, + "verify_ssl": True, + } + entry_check = hass.config_entries.async_get_entry("1") + assert entry_check.state == config_entries.ConfigEntryState.LOADED + assert entry_check.update_listeners is not None diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py new file mode 100644 index 00000000000..021790e65c3 --- /dev/null +++ b/tests/components/scrape/test_init.py @@ -0,0 +1,89 @@ +"""Test Scrape component setup process.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant.components.scrape.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import MockRestData + +from tests.common import MockConfigEntry + +TEST_CONFIG = { + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0, + "verify_ssl": True, +} + + +async def test_setup_entry(hass: HomeAssistant) -> None: + """Test setup entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options=TEST_CONFIG, + title="Release", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.release") + assert state + + +async def test_setup_entry_no_data_fails(hass: HomeAssistant) -> None: + """Test setup entry no data fails.""" + entry = MockConfigEntry( + domain=DOMAIN, data={}, options=TEST_CONFIG, title="Release", entry_id="1" + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor_no_data"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ha_version") + assert state is None + entry = hass.config_entries.async_get_entry("1") + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_remove_entry(hass: HomeAssistant) -> None: + """Test remove entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options=TEST_CONFIG, + title="Release", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.release") + assert state + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.release") + assert not state diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index aaf156208ef..cd4e27e88a2 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -3,10 +3,15 @@ from __future__ import annotations from unittest.mock import patch +import pytest + from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor.const import CONF_STATE_CLASS +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_NAME, + CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS, @@ -15,22 +20,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -from . import MockRestData, return_config +from . import MockRestData, init_integration, return_config + +from tests.common import MockConfigEntry DOMAIN = "scrape" async def test_scrape_sensor(hass: HomeAssistant) -> None: """Test Scrape sensor minimal.""" - config = {"sensor": return_config(select=".current-version h1", name="HA version")} - - mocker = MockRestData("test_scrape_sensor") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config(select=".current-version h1", name="HA version"), + "test_scrape_sensor", + ) state = hass.states.get("sensor.ha_version") assert state.state == "Current Version: 2021.12.10" @@ -38,21 +41,15 @@ async def test_scrape_sensor(hass: HomeAssistant) -> None: async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: """Test Scrape sensor with value template.""" - config = { - "sensor": return_config( + await init_integration( + hass, + return_config( select=".current-version h1", name="HA version", template="{{ value.split(':')[1] }}", - ) - } - - mocker = MockRestData("test_scrape_sensor") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + ), + "test_scrape_sensor", + ) state = hass.states.get("sensor.ha_version") assert state.state == "2021.12.10" @@ -60,24 +57,18 @@ async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: """Test Scrape sensor for unit of measurement, device class and state class.""" - config = { - "sensor": return_config( + await init_integration( + hass, + return_config( select=".current-temp h3", name="Current Temp", template="{{ value.split(':')[1] }}", uom="°C", device_class="temperature", state_class="measurement", - ) - } - - mocker = MockRestData("test_scrape_uom_and_classes") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + ), + "test_scrape_uom_and_classes", + ) state = hass.states.get("sensor.current_temp") assert state.state == "22.1" @@ -88,31 +79,28 @@ async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: async def test_scrape_sensor_authentication(hass: HomeAssistant) -> None: """Test Scrape sensor with authentication.""" - config = { - "sensor": [ - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - return_config( - select=".return", - name="Auth page2", - username="user@secret.com", - password="12345678", - ), - ] - } - - mocker = MockRestData("test_scrape_sensor_authentication") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config( + select=".return", + name="Auth page", + username="user@secret.com", + password="12345678", + authentication="digest", + ), + "test_scrape_sensor_authentication", + ) + await init_integration( + hass, + return_config( + select=".return", + name="Auth page2", + username="user@secret.com", + password="12345678", + ), + "test_scrape_sensor_authentication", + entry_id="2", + ) state = hass.states.get("sensor.auth_page") assert state.state == "secret text" @@ -122,15 +110,11 @@ async def test_scrape_sensor_authentication(hass: HomeAssistant) -> None: async def test_scrape_sensor_no_data(hass: HomeAssistant) -> None: """Test Scrape sensor fails on no data.""" - config = {"sensor": return_config(select=".current-version h1", name="HA version")} - - mocker = MockRestData("test_scrape_sensor_no_data") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config(select=".current-version h1", name="HA version"), + "test_scrape_sensor_no_data", + ) state = hass.states.get("sensor.ha_version") assert state is None @@ -138,14 +122,21 @@ async def test_scrape_sensor_no_data(hass: HomeAssistant) -> None: async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: """Test Scrape sensor no data on refresh.""" - config = {"sensor": return_config(select=".current-version h1", name="HA version")} + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options=return_config(select=".current-version h1", name="HA version"), + entry_id="1", + ) + config_entry.add_to_hass(hass) mocker = MockRestData("test_scrape_sensor") with patch( - "homeassistant.components.scrape.sensor.RestData", + "homeassistant.components.scrape.RestData", return_value=mocker, ): - assert await async_setup_component(hass, "sensor", config) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") @@ -162,20 +153,17 @@ async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: async def test_scrape_sensor_attribute_and_tag(hass: HomeAssistant) -> None: """Test Scrape sensor with attribute and tag.""" - config = { - "sensor": [ - return_config(select="div", name="HA class", index=1, attribute="class"), - return_config(select="template", name="HA template"), - ] - } - - mocker = MockRestData("test_scrape_sensor") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config(select="div", name="HA class", index=1, attribute="class"), + "test_scrape_sensor", + ) + await init_integration( + hass, + return_config(select="template", name="HA template"), + "test_scrape_sensor", + entry_id="2", + ) state = hass.states.get("sensor.ha_class") assert state.state == "['links']" @@ -185,22 +173,55 @@ async def test_scrape_sensor_attribute_and_tag(hass: HomeAssistant) -> None: async def test_scrape_sensor_errors(hass: HomeAssistant) -> None: """Test Scrape sensor handle errors.""" - config = { - "sensor": [ - return_config(select="div", name="HA class", index=5, attribute="class"), - return_config(select="div", name="HA class2", attribute="classes"), - ] - } - - mocker = MockRestData("test_scrape_sensor") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config(select="div", name="HA class", index=5, attribute="class"), + "test_scrape_sensor", + ) + await init_integration( + hass, + return_config(select="div", name="HA class2", attribute="classes"), + "test_scrape_sensor", + entry_id="2", + ) state = hass.states.get("sensor.ha_class") assert state.state == STATE_UNKNOWN state2 = hass.states.get("sensor.ha_class2") assert state2.state == STATE_UNKNOWN + + +async def test_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test the Scrape sensor import.""" + config = { + "sensor": { + "platform": "scrape", + "resource": "https://www.home-assistant.io", + "select": ".current-version h1", + "name": "HA Version", + "index": 0, + "verify_ssl": True, + "value_template": "{{ value.split(':')[1] }}", + } + } + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.scrape.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + assert ( + "Loading Scrape via platform setup has been deprecated in Home Assistant" + in caplog.text + ) + + assert hass.config_entries.async_entries(DOMAIN) + options = hass.config_entries.async_entries(DOMAIN)[0].options + assert options[CONF_NAME] == "HA Version" + assert options[CONF_RESOURCE] == "https://www.home-assistant.io" + + state = hass.states.get("sensor.ha_version") + assert state.state == "2021.12.10"