Allow removal of sensor settings in scrape (#90412)

* Allow removal of sensor settings in scrape

* Adjust

* Adjust

* Add comment

* Simplify

* Simplify

* Adjust

* Don't allow empty string

* Only allow None

* Use default as None

* Use sentinel "none"

* Not needed

* Adjust unit of measurement

* Add translation keys for "none"

* Use translations

* Sort

* Add enum and timestamp

* Use translation references

* Remove default and set suggested_values

* Disallow enum device class

* Adjust tests

* Adjust _strip_sentinel
pull/90579/head
epenet 2023-03-31 14:34:20 +02:00 committed by GitHub
parent ea32cc5d92
commit 4f54e33f67
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 294 additions and 18 deletions

View File

@ -95,6 +95,8 @@ RESOURCE_SETUP = {
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(),
} }
NONE_SENTINEL = "none"
SENSOR_SETUP = { SENSOR_SETUP = {
vol.Required(CONF_SELECT): TextSelector(), vol.Required(CONF_SELECT): TextSelector(),
vol.Optional(CONF_INDEX, default=0): NumberSelector( vol.Optional(CONF_INDEX, default=0): NumberSelector(
@ -102,28 +104,45 @@ SENSOR_SETUP = {
), ),
vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_ATTRIBUTE): TextSelector(),
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
vol.Optional(CONF_DEVICE_CLASS): SelectSelector( vol.Required(CONF_DEVICE_CLASS): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=[cls.value for cls in SensorDeviceClass], options=[NONE_SENTINEL]
+ sorted(
[
cls.value
for cls in SensorDeviceClass
if cls != SensorDeviceClass.ENUM
]
),
mode=SelectSelectorMode.DROPDOWN, mode=SelectSelectorMode.DROPDOWN,
translation_key="device_class",
) )
), ),
vol.Optional(CONF_STATE_CLASS): SelectSelector( vol.Required(CONF_STATE_CLASS): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=[cls.value for cls in SensorStateClass], options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]),
mode=SelectSelectorMode.DROPDOWN, mode=SelectSelectorMode.DROPDOWN,
translation_key="state_class",
) )
), ),
vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
SelectSelectorConfig( SelectSelectorConfig(
options=[cls.value for cls in UnitOfTemperature], options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]),
custom_value=True, custom_value=True,
mode=SelectSelectorMode.DROPDOWN, mode=SelectSelectorMode.DROPDOWN,
translation_key="unit_of_measurement",
) )
), ),
} }
def _strip_sentinel(options: dict[str, Any]) -> None:
"""Convert sentinel to None."""
for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT):
if options[key] == NONE_SENTINEL:
options.pop(key)
async def validate_rest_setup( async def validate_rest_setup(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any] handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -150,6 +169,7 @@ async def validate_sensor_setup(
# Standard behavior is to merge the result with the options. # Standard behavior is to merge the result with the options.
# In this case, we want to add a sub-item so we update the options directly. # In this case, we want to add a sub-item so we update the options directly.
sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, [])
_strip_sentinel(user_input)
sensors.append(user_input) sensors.append(user_input)
return {} return {}
@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values(
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return suggested values for sensor editing.""" """Return suggested values for sensor editing."""
idx: int = handler.flow_state["_idx"] idx: int = handler.flow_state["_idx"]
return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx]) suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx])
for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT):
if not suggested_values.get(key):
suggested_values[key] = NONE_SENTINEL
return suggested_values
async def validate_sensor_edit( async def validate_sensor_edit(
@ -194,6 +218,7 @@ async def validate_sensor_edit(
# In this case, we want to add a sub-item so we update the options directly. # In this case, we want to add a sub-item so we update the options directly.
idx: int = handler.flow_state["_idx"] idx: int = handler.flow_state["_idx"]
handler.options[SENSOR_DOMAIN][idx].update(user_input) handler.options[SENSOR_DOMAIN][idx].update(user_input)
_strip_sentinel(handler.options[SENSOR_DOMAIN][idx])
return {} return {}

View File

@ -125,5 +125,72 @@
} }
} }
} }
},
"selector": {
"device_class": {
"options": {
"none": "No device class",
"date": "[%key:component::sensor::entity_component::date::name%]",
"duration": "[%key:component::sensor::entity_component::duration::name%]",
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
"battery": "[%key:component::sensor::entity_component::battery::name%]",
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"current": "[%key:component::sensor::entity_component::current::name%]",
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
"distance": "[%key:component::sensor::entity_component::distance::name%]",
"energy": "[%key:component::sensor::entity_component::energy::name%]",
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
"gas": "[%key:component::sensor::entity_component::gas::name%]",
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
"power": "[%key:component::sensor::entity_component::power::name%]",
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
"speed": "[%key:component::sensor::entity_component::speed::name%]",
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
"volume": "[%key:component::sensor::entity_component::volume::name%]",
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
"water": "[%key:component::sensor::entity_component::water::name%]",
"weight": "[%key:component::sensor::entity_component::weight::name%]",
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
},
"state_class": {
"options": {
"none": "No state class",
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
}
},
"unit_of_measurement": {
"options": {
"none": "No unit of measurement"
}
}
} }
} }

View File

@ -1,8 +1,9 @@
"""Fixtures for the Scrape integration.""" """Fixtures for the Scrape integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator
from typing import Any from typing import Any
from unittest.mock import patch from unittest.mock import AsyncMock, patch
import uuid import uuid
import pytest import pytest
@ -32,6 +33,16 @@ from . import MockRestData
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Automatically path uuid generator."""
with patch(
"homeassistant.components.scrape.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture(name="get_config") @pytest.fixture(name="get_config")
async def get_config_to_integration_load() -> dict[str, Any]: async def get_config_to_integration_load() -> dict[str, Any]:
"""Return default minimal configuration. """Return default minimal configuration.

View File

@ -1,13 +1,14 @@
"""Test the Scrape config flow.""" """Test the Scrape config flow."""
from __future__ import annotations from __future__ import annotations
from unittest.mock import patch from unittest.mock import AsyncMock, patch
import uuid import uuid
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.data import DEFAULT_TIMEOUT
from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.rest.schema import DEFAULT_METHOD
from homeassistant.components.scrape import DOMAIN from homeassistant.components.scrape import DOMAIN
from homeassistant.components.scrape.config_flow import NONE_SENTINEL
from homeassistant.components.scrape.const import ( from homeassistant.components.scrape.const import (
CONF_ENCODING, CONF_ENCODING,
CONF_INDEX, CONF_INDEX,
@ -15,14 +16,18 @@ from homeassistant.components.scrape.const import (
DEFAULT_ENCODING, DEFAULT_ENCODING,
DEFAULT_VERIFY_SSL, DEFAULT_VERIFY_SSL,
) )
from homeassistant.components.sensor import CONF_STATE_CLASS
from homeassistant.const import ( from homeassistant.const import (
CONF_DEVICE_CLASS,
CONF_METHOD, CONF_METHOD,
CONF_NAME, CONF_NAME,
CONF_PASSWORD, CONF_PASSWORD,
CONF_RESOURCE, CONF_RESOURCE,
CONF_TIMEOUT, CONF_TIMEOUT,
CONF_UNIQUE_ID, CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME, CONF_USERNAME,
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL, CONF_VERIFY_SSL,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -34,7 +39,9 @@ from . import MockRestData
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: async def test_form(
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
) -> None:
"""Test we get the form.""" """Test we get the form."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None:
with patch( with patch(
"homeassistant.components.rest.RestData", "homeassistant.components.rest.RestData",
return_value=get_data, return_value=get_data,
) as mock_data, patch( ) as mock_data:
"homeassistant.components.scrape.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ {
@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None:
CONF_NAME: "Current version", CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1", CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0, CONF_INDEX: 0.0,
CONF_DEVICE_CLASS: NONE_SENTINEL,
CONF_STATE_CLASS: NONE_SENTINEL,
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None:
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: async def test_flow_fails(
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
) -> None:
"""Test config flow error.""" """Test config flow error."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
@ -137,9 +146,6 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None:
with patch( with patch(
"homeassistant.components.rest.RestData", "homeassistant.components.rest.RestData",
return_value=get_data, return_value=get_data,
), patch(
"homeassistant.components.scrape.async_setup_entry",
return_value=True,
): ):
result3 = await hass.config_entries.flow.async_configure( result3 = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None:
CONF_NAME: "Current version", CONF_NAME: "Current version",
CONF_SELECT: ".current-version h1", CONF_SELECT: ".current-version h1",
CONF_INDEX: 0.0, CONF_INDEX: 0.0,
CONF_DEVICE_CLASS: NONE_SENTINEL,
CONF_STATE_CLASS: NONE_SENTINEL,
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow(
CONF_NAME: "Template", CONF_NAME: "Template",
CONF_SELECT: "template", CONF_SELECT: "template",
CONF_INDEX: 0.0, CONF_INDEX: 0.0,
CONF_DEVICE_CLASS: NONE_SENTINEL,
CONF_STATE_CLASS: NONE_SENTINEL,
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow(
user_input={ user_input={
CONF_SELECT: "template", CONF_SELECT: "template",
CONF_INDEX: 0.0, CONF_INDEX: 0.0,
CONF_DEVICE_CLASS: NONE_SENTINEL,
CONF_STATE_CLASS: NONE_SENTINEL,
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow(
# Check the state of the entity has changed as expected # Check the state of the entity has changed as expected
state = hass.states.get("sensor.current_version") state = hass.states.get("sensor.current_version")
assert state.state == "Trying to get" assert state.state == "Trying to get"
async def test_sensor_options_add_device_class(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test options flow to edit a sensor."""
entry = MockConfigEntry(
domain=DOMAIN,
options={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
},
entry_id="1",
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "select_edit_sensor"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "select_edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"index": "0"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0.0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
},
)
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,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_INDEX: 0,
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
],
}
async def test_sensor_options_remove_device_class(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test options flow to edit a sensor."""
entry = MockConfigEntry(
domain=DOMAIN,
options={
CONF_RESOURCE: "https://www.home-assistant.io",
CONF_METHOD: DEFAULT_METHOD,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
CONF_TIMEOUT: DEFAULT_TIMEOUT,
CONF_ENCODING: DEFAULT_ENCODING,
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_DEVICE_CLASS: "temperature",
CONF_STATE_CLASS: "measurement",
CONF_UNIT_OF_MEASUREMENT: "°C",
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
}
],
},
entry_id="1",
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"next_step_id": "select_edit_sensor"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "select_edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
{"index": "0"},
)
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "edit_sensor"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_SELECT: ".current-temp h3",
CONF_INDEX: 0.0,
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_DEVICE_CLASS: NONE_SENTINEL,
CONF_STATE_CLASS: NONE_SENTINEL,
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
},
)
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,
CONF_ENCODING: "UTF-8",
"sensor": [
{
CONF_NAME: "Current Temp",
CONF_SELECT: ".current-temp h3",
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
CONF_INDEX: 0,
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
},
],
}