Add config flow to Scrape (#81193)
* Scrape take 2 * cleanup * new entity name * Fix name, add tests * Use FlowResultType * Add test abort * hassfest * Remove not needed test * clean * Remove config entry and implement datacoordinator * fix codeowners * fix codeowners * codeowners reset * Fix coordinator * Remove test config_flow * Fix tests * hassfest * reset config flow * reset strings * reset sensor * Reconfig * Fix tests * coverage * Remove coverage * Remove print * Add config flow * Fix config flow * Add back init * Add entry to sensor * float to int * Fix SelectSelector * Add tests for config entry to existing * Test config flow * Fix test reload * Fix rebase * Fix strings * clean init * Clean test_sensor * Align sensor setup entry * Add error to strings * review changes * clean tests * Add back options flow * review changes * update_listener * Add tests * Remove duplicate abort * strings * sensors to sensor * review changes * more review changes * clarify test payload * fixture name
This commit is contained in:
parent
848821139d
commit
b3dd59f202
13 changed files with 528 additions and 87 deletions
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -347,6 +347,7 @@ FLOWS = {
|
|||
"ruuvitag_ble",
|
||||
"sabnzbd",
|
||||
"samsungtv",
|
||||
"scrape",
|
||||
"screenlogic",
|
||||
"season",
|
||||
"sense",
|
||||
|
|
|
@ -4597,7 +4597,7 @@
|
|||
"scrape": {
|
||||
"name": "Scrape",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"screenaway": {
|
||||
|
|
|
@ -98,11 +98,20 @@ class MockRestData:
|
|||
self.count += 1
|
||||
if self.payload == "test_scrape_sensor":
|
||||
self.data = (
|
||||
# Default
|
||||
"<div class='current-version material-card text'>"
|
||||
"<h1>Current Version: 2021.12.10</h1>Released: <span class='release-date'>January 17, 2022</span>"
|
||||
"<div class='links' style='links'><a href='/latest-release-notes/'>Release notes</a></div></div>"
|
||||
"<template>Trying to get</template>"
|
||||
)
|
||||
if self.payload == "test_scrape_sensor2":
|
||||
self.data = (
|
||||
# Hidden version
|
||||
"<div class='current-version material-card text'>"
|
||||
"<h1>Hidden Version: 2021.12.10</h1>Released: <span class='release-date'>January 17, 2022</span>"
|
||||
"<div class='links' style='links'><a href='/latest-release-notes/'>Release notes</a></div></div>"
|
||||
"<template>Trying to get</template>"
|
||||
)
|
||||
if self.payload == "test_scrape_uom_and_classes":
|
||||
self.data = (
|
||||
"<div class='current-temp temp-card text'>"
|
||||
|
|
80
tests/components/scrape/conftest.py
Normal file
80
tests/components/scrape/conftest.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
"""Fixtures for the Scrape integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.rest.data import DEFAULT_TIMEOUT
|
||||
from homeassistant.components.rest.schema import DEFAULT_METHOD, DEFAULT_VERIFY_SSL
|
||||
from homeassistant.components.scrape.const import CONF_INDEX, CONF_SELECT, DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import (
|
||||
CONF_METHOD,
|
||||
CONF_NAME,
|
||||
CONF_RESOURCE,
|
||||
CONF_TIMEOUT,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import MockRestData
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(name="get_config")
|
||||
async def get_config_to_integration_load() -> dict[str, Any]:
|
||||
"""Return default minimal configuration.
|
||||
|
||||
To override the config, tests can be marked with:
|
||||
@pytest.mark.parametrize("get_config", [{...}])
|
||||
"""
|
||||
return {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: DEFAULT_METHOD,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="get_data")
|
||||
async def get_data_to_integration_load() -> MockRestData:
|
||||
"""Return RestData.
|
||||
|
||||
To override the config, tests can be marked with:
|
||||
@pytest.mark.parametrize("get_data", [{...}])
|
||||
"""
|
||||
return MockRestData("test_scrape_sensor")
|
||||
|
||||
|
||||
@pytest.fixture(name="loaded_entry")
|
||||
async def load_integration(
|
||||
hass: HomeAssistant, get_config: dict[str, Any], get_data: MockRestData
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Scrape integration in Home Assistant."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
source=SOURCE_USER,
|
||||
options=get_config,
|
||||
entry_id="1",
|
||||
)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return config_entry
|
206
tests/components/scrape/test_config_flow.py
Normal file
206
tests/components/scrape/test_config_flow.py
Normal file
|
@ -0,0 +1,206 @@
|
|||
"""Test the Scrape config flow."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.rest.data import DEFAULT_TIMEOUT
|
||||
from homeassistant.components.rest.schema import DEFAULT_METHOD
|
||||
from homeassistant.components.scrape import DOMAIN
|
||||
from homeassistant.components.scrape.const import (
|
||||
CONF_INDEX,
|
||||
CONF_SELECT,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_METHOD,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_RESOURCE,
|
||||
CONF_TIMEOUT,
|
||||
CONF_USERNAME,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import MockRestData
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
"""Test we get the form."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
) as mock_data, 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_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result2["flow_id"],
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result3["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result3["version"] == 1
|
||||
assert result3["options"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert len(mock_data.mock_calls) == 1
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
"""Test config flow error."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == config_entries.SOURCE_USER
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
side_effect=HomeAssistantError,
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2["errors"] == {"base": "resource_error"}
|
||||
|
||||
with patch("homeassistant.components.rest.RestData", return_value=get_data,), patch(
|
||||
"homeassistant.components.scrape.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result4 = await hass.config_entries.flow.async_configure(
|
||||
result3["flow_id"],
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result4["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result4["title"] == "https://www.home-assistant.io"
|
||||
assert result4["options"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None:
|
||||
"""Test options config flow."""
|
||||
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Current Version: 2021.12.10"
|
||||
|
||||
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
mocker = MockRestData("test_scrape_sensor2")
|
||||
with patch("homeassistant.components.rest.RestData", return_value=mocker):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: DEFAULT_METHOD,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_USERNAME: "secret_username",
|
||||
CONF_PASSWORD: "secret_password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10.0,
|
||||
CONF_USERNAME: "secret_username",
|
||||
CONF_PASSWORD: "secret_password",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check the entity was updated, no new entity was created
|
||||
assert len(hass.states.async_all()) == 1
|
||||
|
||||
# Check the state of the entity has changed as expected
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Hidden Version: 2021.12.10"
|
|
@ -6,6 +6,7 @@ from unittest.mock import patch
|
|||
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
@ -13,7 +14,7 @@ from homeassistant.setup import async_setup_component
|
|||
|
||||
from . import MockRestData, return_integration_config
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
async def test_setup_config(hass: HomeAssistant) -> None:
|
||||
|
@ -109,3 +110,18 @@ async def test_setup_config_no_sensors(
|
|||
):
|
||||
assert await async_setup_component(hass, DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_setup_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None:
|
||||
"""Test setup entry."""
|
||||
|
||||
assert loaded_entry.state == config_entries.ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None:
|
||||
"""Test unload an entry."""
|
||||
|
||||
assert loaded_entry.state == config_entries.ConfigEntryState.LOADED
|
||||
assert await hass.config_entries.async_unload(loaded_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert loaded_entry.state is config_entries.ConfigEntryState.NOT_LOADED
|
||||
|
|
|
@ -26,7 +26,7 @@ from homeassistant.setup import async_setup_component
|
|||
|
||||
from . import MockRestData, return_config, return_integration_config
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
DOMAIN = "scrape"
|
||||
|
||||
|
@ -405,3 +405,12 @@ async def test_scrape_sensor_unique_id(hass: HomeAssistant) -> None:
|
|||
entity = entity_reg.async_get("sensor.ha_version")
|
||||
|
||||
assert entity.unique_id == "ha_version_unique_id"
|
||||
|
||||
|
||||
async def test_setup_config_entry(
|
||||
hass: HomeAssistant, loaded_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test setup from config entry."""
|
||||
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Current Version: 2021.12.10"
|
||||
|
|
Loading…
Add table
Reference in a new issue