diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index 1e3635a010c..3ca13e56b29 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -95,6 +95,8 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } +NONE_SENTINEL = "none" + SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -102,28 +104,45 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + vol.Required(CONF_DEVICE_CLASS): SelectSelector( 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, + translation_key="device_class", ) ), - vol.Optional(CONF_STATE_CLASS): SelectSelector( + vol.Required(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in SensorStateClass], + options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), mode=SelectSelectorMode.DROPDOWN, + translation_key="state_class", ) ), - vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[cls.value for cls in UnitOfTemperature], + options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), custom_value=True, 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( handler: SchemaCommonFlowHandler, user_input: 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. # 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, []) + _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" 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( @@ -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. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) + _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 052ef22848f..857d53eb527 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -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" + } + } } } diff --git a/tests/components/scrape/conftest.py b/tests/components/scrape/conftest.py index 5ad4f39844e..026daeea38c 100644 --- a/tests/components/scrape/conftest.py +++ b/tests/components/scrape/conftest.py @@ -1,8 +1,9 @@ """Fixtures for the Scrape integration.""" from __future__ import annotations +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import pytest @@ -32,6 +33,16 @@ from . import MockRestData 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") async def get_config_to_integration_load() -> dict[str, Any]: """Return default minimal configuration. diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index e508937fed8..9c6c5e0b4de 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Scrape config flow.""" from __future__ import annotations -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid 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.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -15,14 +16,18 @@ from homeassistant.components.scrape.const import ( DEFAULT_ENCODING, DEFAULT_VERIFY_SSL, ) +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_METHOD, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant @@ -34,7 +39,9 @@ from . import MockRestData 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.""" result = await hass.config_entries.flow.async_init( @@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: 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: + ) as mock_data: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", 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() @@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None: 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.""" 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( "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"], @@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None: CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", 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() @@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", 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() @@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", 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() @@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow( # Check the state of the entity has changed as expected state = hass.states.get("sensor.current_version") 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", + }, + ], + }