From 0658c7b307adfdc3cd866e34e86e2d231f7c22bb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Oct 2023 13:01:27 +0200 Subject: [PATCH] Add config flow to random (#100858) Co-authored-by: Robert Resch --- homeassistant/components/random/__init__.py | 23 ++ .../components/random/binary_sensor.py | 31 ++- .../components/random/config_flow.py | 186 ++++++++++++++++ homeassistant/components/random/const.py | 5 + homeassistant/components/random/manifest.json | 4 +- homeassistant/components/random/sensor.py | 41 ++-- homeassistant/components/random/strings.json | 48 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 12 +- tests/components/random/test_config_flow.py | 201 ++++++++++++++++++ 10 files changed, 524 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/random/config_flow.py create mode 100644 homeassistant/components/random/const.py create mode 100644 homeassistant/components/random/strings.json create mode 100644 tests/components/random/test_config_flow.py diff --git a/homeassistant/components/random/__init__.py b/homeassistant/components/random/__init__.py index 01bde80b0c3..89a772529bd 100644 --- a/homeassistant/components/random/__init__.py +++ b/homeassistant/components/random/__init__.py @@ -1 +1,24 @@ """The random component.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups( + entry, (entry.options["entity_type"],) + ) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms( + entry, (entry.options["entity_type"],) + ) diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 5e688162124..33d60d4bfd8 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -1,7 +1,9 @@ """Support for showing random states.""" from __future__ import annotations +from collections.abc import Mapping from random import getrandbits +from typing import Any import voluptuous as vol @@ -10,6 +12,7 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -33,20 +36,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Random binary sensor.""" - name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) - async_add_entities([RandomSensor(name, device_class)], True) + async_add_entities([RandomBinarySensor(config)], True) -class RandomSensor(BinarySensorEntity): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + async_add_entities( + [RandomBinarySensor(config_entry.options, config_entry.entry_id)], True + ) + + +class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" - def __init__(self, name, device_class): + _state: bool | None = None + + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" - self._name = name - self._device_class = device_class - self._state = None + self._name = config.get(CONF_NAME) + self._device_class = config.get(CONF_DEVICE_CLASS) + if entry_id: + self._attr_unique_id = entry_id @property def name(self): diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py new file mode 100644 index 00000000000..96dde9c8742 --- /dev/null +++ b/homeassistant/components/random/config_flow.py @@ -0,0 +1,186 @@ +"""Config flow for Random helper.""" +from collections.abc import Callable, Coroutine, Mapping +from enum import StrEnum +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import DEVICE_CLASS_UNITS, SensorDeviceClass +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_MAXIMUM, + CONF_MINIMUM, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + Platform, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import DOMAIN +from .sensor import DEFAULT_MAX, DEFAULT_MIN + + +class _FlowType(StrEnum): + CONFIG = "config" + OPTION = "option" + + +def _generate_schema(domain: str, flow_type: _FlowType) -> vol.Schema: + """Generate schema.""" + schema: dict[vol.Marker, Any] = {} + + if flow_type == _FlowType.CONFIG: + schema[vol.Required(CONF_NAME)] = TextSelector() + + if domain == Platform.BINARY_SENSOR: + schema[vol.Optional(CONF_DEVICE_CLASS)] = SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in BinarySensorDeviceClass], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="binary_sensor_device_class", + ), + ) + + if domain == Platform.SENSOR: + schema.update( + { + vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): cv.positive_int, + vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): cv.positive_int, + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="sensor_device_class", + ), + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[ + str(unit) + for units in DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + ], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="sensor_unit_of_measurement", + custom_value=True, + ), + ), + } + ) + + return vol.Schema(schema) + + +async def choose_options_step(options: dict[str, Any]) -> str: + """Return next step_id for options flow according to template_type.""" + return cast(str, options["entity_type"]) + + +def _validate_unit(options: dict[str, Any]) -> None: + """Validate unit of measurement.""" + if ( + (device_class := options.get(CONF_DEVICE_CLASS)) + and (units := DEVICE_CLASS_UNITS.get(device_class)) + and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units + ): + sorted_units = sorted( + [f"'{str(unit)}'" if unit else "no unit of measurement" for unit in units], + key=str.casefold, + ) + if len(sorted_units) == 1: + units_string = sorted_units[0] + else: + units_string = f"one of {', '.join(sorted_units)}" + + raise vol.Invalid( + f"'{unit}' is not a valid unit for device class '{device_class}'; " + f"expected {units_string}" + ) + + +def validate_user_input( + template_type: str, +) -> Callable[ + [SchemaCommonFlowHandler, dict[str, Any]], + Coroutine[Any, Any, dict[str, Any]], +]: + """Do post validation of user input. + + For sensors: Validate unit of measurement. + """ + + async def _validate_user_input( + _: SchemaCommonFlowHandler, + user_input: dict[str, Any], + ) -> dict[str, Any]: + """Add template type to user input.""" + if template_type == Platform.SENSOR: + _validate_unit(user_input) + return {"entity_type": template_type} | user_input + + return _validate_user_input + + +RANDOM_TYPES = [ + Platform.BINARY_SENSOR.value, + Platform.SENSOR.value, +] + +CONFIG_FLOW = { + "user": SchemaFlowMenuStep(RANDOM_TYPES), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.BINARY_SENSOR, _FlowType.CONFIG), + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.SENSOR, _FlowType.CONFIG), + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(next_step=choose_options_step), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.BINARY_SENSOR, _FlowType.OPTION), + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.SENSOR, _FlowType.OPTION), + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +class RandomConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle config flow for random helper.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + @callback + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) diff --git a/homeassistant/components/random/const.py b/homeassistant/components/random/const.py new file mode 100644 index 00000000000..df6a18f8d11 --- /dev/null +++ b/homeassistant/components/random/const.py @@ -0,0 +1,5 @@ +"""Constants for random helper.""" +DOMAIN = "random" + +DEFAULT_MIN = 0 +DEFAULT_MAX = 20 diff --git a/homeassistant/components/random/manifest.json b/homeassistant/components/random/manifest.json index 164445fd8ed..36396f0a1f6 100644 --- a/homeassistant/components/random/manifest.json +++ b/homeassistant/components/random/manifest.json @@ -2,7 +2,9 @@ "domain": "random", "name": "Random", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/random", - "iot_class": "local_polling", + "integration_type": "helper", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index d4db30fd61e..18b383b401e 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -1,12 +1,16 @@ """Support for showing random numbers.""" from __future__ import annotations +from collections.abc import Mapping from random import randrange +from typing import Any import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME, @@ -17,12 +21,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_MAX, DEFAULT_MIN + ATTR_MAXIMUM = "maximum" ATTR_MINIMUM = "minimum" DEFAULT_NAME = "Random Sensor" -DEFAULT_MIN = 0 -DEFAULT_MAX = 20 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -42,26 +46,37 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Random number sensor.""" - name = config.get(CONF_NAME) - minimum = config.get(CONF_MINIMUM) - maximum = config.get(CONF_MAXIMUM) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - async_add_entities([RandomSensor(name, minimum, maximum, unit)], True) + async_add_entities([RandomSensor(config)], True) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + + async_add_entities( + [RandomSensor(config_entry.options, config_entry.entry_id)], True + ) class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" _attr_icon = "mdi:hanger" + _state: int | None = None - def __init__(self, name, minimum, maximum, unit_of_measurement): + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" - self._name = name - self._minimum = minimum - self._maximum = maximum - self._unit_of_measurement = unit_of_measurement - self._state = None + self._name = config.get(CONF_NAME) + self._minimum = config.get(CONF_MINIMUM, DEFAULT_MIN) + self._maximum = config.get(CONF_MAXIMUM, DEFAULT_MAX) + self._unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + if entry_id: + self._attr_unique_id = entry_id @property def name(self): diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json new file mode 100644 index 00000000000..164f184ae88 --- /dev/null +++ b/homeassistant/components/random/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "binary_sensor": { + "data": { + "device_class": "[%key:component::random::config::step::sensor::data::device_class%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "title": "Random binary sensor" + }, + "sensor": { + "data": { + "device_class": "Device class", + "name": "[%key:common::config_flow::data::name%]", + "minimum": "Minimum", + "maximum": "Maximum", + "unit_of_measurement": "Unit of measurement" + }, + "title": "Random sensor" + }, + "user": { + "description": "This helper allow you to create a helper that emits a random value.", + "menu_options": { + "binary_sensor": "Random binary sensor", + "sensor": "Random sensor" + }, + "title": "Random helper" + } + } + }, + "options": { + "step": { + "binary_sensor": { + "title": "[%key:component::random::config::step::binary_sensor::title%]", + "description": "This helper does not have any options." + }, + "sensor": { + "data": { + "device_class": "[%key:component::random::config::step::sensor::data::device_class%]", + "minimum": "[%key:component::random::config::step::sensor::data::minimum%]", + "maximum": "[%key:component::random::config::step::sensor::data::maximum%]", + "unit_of_measurement": "[%key:component::random::config::step::sensor::data::unit_of_measurement%]" + }, + "title": "[%key:component::random::config::step::sensor::title%]" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 99b947d3c52..5cd89432197 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = { "group", "integration", "min_max", + "random", "switch_as_x", "template", "threshold", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ad3d3f6f05a..9d8ac60ee51 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4596,12 +4596,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "random": { - "name": "Random", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "rapt_ble": { "name": "RAPT Bluetooth", "integration_type": "hub", @@ -6769,6 +6763,12 @@ "config_flow": true, "iot_class": "calculated" }, + "random": { + "name": "Random", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "schedule": { "integration_type": "helper", "config_flow": false diff --git a/tests/components/random/test_config_flow.py b/tests/components/random/test_config_flow.py new file mode 100644 index 00000000000..909e866ea92 --- /dev/null +++ b/tests/components/random/test_config_flow.py @@ -0,0 +1,201 @@ +"""Test the Random config flow.""" +from typing import Any +from unittest.mock import patch + +import pytest +from voluptuous import Invalid + +from homeassistant import config_entries +from homeassistant.components.random import async_setup_entry +from homeassistant.components.random.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ( + "entity_type", + "extra_input", + "extra_options", + ), + ( + ( + "binary_sensor", + {}, + {}, + ), + ( + "sensor", + { + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + }, + { + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + "minimum": 0, + "maximum": 20, + }, + ), + ( + "sensor", + {}, + {"minimum": 0, "maximum": 20}, + ), + ), +) +async def test_config_flow( + hass: HomeAssistant, + entity_type: str, + extra_input: dict[str, Any], + extra_options: dict[str, Any], +) -> None: + """Test the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": entity_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == entity_type + + with patch( + "homeassistant.components.random.async_setup_entry", wraps=async_setup_entry + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My random entity", + **extra_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My random entity" + assert result["data"] == {} + assert result["options"] == { + "name": "My random entity", + "entity_type": entity_type, + **extra_options, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("device_class", "unit_of_measurement"), + [ + (SensorDeviceClass.POWER, UnitOfEnergy.WATT_HOUR), + (SensorDeviceClass.ILLUMINANCE, UnitOfEnergy.WATT_HOUR), + ], +) +async def test_wrong_uom( + hass: HomeAssistant, device_class: SensorDeviceClass, unit_of_measurement: str +) -> None: + """Test entering a wrong unit of measurement.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "sensor" + + with pytest.raises(Invalid, match="is not a valid unit for device class"): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My random entity", + "device_class": device_class, + "unit_of_measurement": unit_of_measurement, + }, + ) + + +@pytest.mark.parametrize( + ( + "entity_type", + "extra_options", + "options_options", + ), + ( + ( + "sensor", + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.WATT_HOUR, + "minimum": 0, + "maximum": 20, + }, + { + "minimum": 10, + "maximum": 20, + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + }, + ), + ), +) +async def test_options( + hass: HomeAssistant, + entity_type: str, + extra_options, + options_options, +) -> None: + """Test reconfiguring.""" + + random_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My random", + "entity_type": entity_type, + **extra_options, + }, + title="My random", + ) + random_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(random_config_entry.entry_id) + await hass.async_block_till_done() + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == entity_type + assert "name" not in result["data_schema"].schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=options_options, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "My random", + "entity_type": entity_type, + **options_options, + } + assert config_entry.data == {} + assert config_entry.options == { + "name": "My random", + "entity_type": entity_type, + **options_options, + } + assert config_entry.title == "My random"