diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 0504cdb8b37..05ef300d33e 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -59,6 +59,8 @@ def main(): # If it's a new integration and it's not a config flow, # create a config flow too. if not args.template.startswith("config_flow"): + if info.helper: + template = "config_flow_helper" if info.oauth2: template = "config_flow_oauth2" elif info.authentication or not info.discoverable: diff --git a/script/scaffold/docs.py b/script/scaffold/docs.py index 3a871301b97..6e31f15e6d4 100644 --- a/script/scaffold/docs.py +++ b/script/scaffold/docs.py @@ -6,6 +6,10 @@ DATA = { "title": "Config Flow", "docs": "https://developers.home-assistant.io/docs/en/config_entries_config_flow_handler.html", }, + "config_flow_helper": { + "title": "Helper Config Flow", + "docs": "https://developers.home-assistant.io/docs/en/config_entries_config_flow_handler.html#helper", + }, "config_flow_discovery": { "title": "Discoverable Config Flow", "docs": "https://developers.home-assistant.io/docs/en/config_entries_config_flow_handler.html#discoverable-integrations-that-require-no-authentication", diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py index 8442650dce4..85a94459d06 100644 --- a/script/scaffold/gather_info.py +++ b/script/scaffold/gather_info.py @@ -119,6 +119,11 @@ More info @ https://developers.home-assistant.io/docs/creating_integration_manif "default": "no", **YES_NO, }, + "helper": { + "prompt": "Is this a helper integration? (yes/no)", + "default": "no", + **YES_NO, + }, "oauth2": { "prompt": "Can the user authenticate the device using OAuth2? (yes/no)", "default": "no", diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 122d8570dc1..56b5252cfad 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -150,6 +150,19 @@ def _custom_tasks(template, info: Info) -> None: }, ) + elif template == "config_flow_helper": + info.update_manifest(config_flow=True) + info.update_strings( + config={ + "step": { + "init": { + "description": "Select the sensor for the NEW_NAME.", + "data": {"entity_id": "Sensor entity"}, + }, + }, + }, + ) + elif template == "config_flow_oauth2": info.update_manifest(config_flow=True, dependencies=["http"]) info.update_strings( diff --git a/script/scaffold/templates/config_flow_helper/integration/__init__.py b/script/scaffold/templates/config_flow_helper/integration/__init__.py new file mode 100644 index 00000000000..e0d115559a1 --- /dev/null +++ b/script/scaffold/templates/config_flow_helper/integration/__init__.py @@ -0,0 +1,39 @@ +"""The NEW_NAME integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up NEW_NAME from a config entry.""" + # TODO Optionally store an object for your platforms to access + # hass.data[DOMAIN][entry.entry_id] = ... + + # TODO Optionally validate config entry options before setting up platform + + hass.config_entries.async_setup_platforms(entry, (Platform.SENSOR,)) + + # TODO Remove if the integration does not have an options flow + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + + return True + + +# TODO Remove if the integration does not have an options flow +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.""" + if unload_ok := await hass.config_entries.async_unload_platforms( + entry, (Platform.SENSOR,) + ): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/script/scaffold/templates/config_flow_helper/integration/config_flow.py b/script/scaffold/templates/config_flow_helper/integration/config_flow.py new file mode 100644 index 00000000000..ad0e8a3eb90 --- /dev/null +++ b/script/scaffold/templates/config_flow_helper/integration/config_flow.py @@ -0,0 +1,46 @@ +"""Config flow for NEW_NAME integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.helpers import selector +from homeassistant.helpers.helper_config_entry_flow import ( + HelperConfigFlowHandler, + HelperFlowStep, +) + +from .const import DOMAIN + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_ENTITY_ID): selector.selector( + {"entity": {"domain": "sensor"}} + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required("name"): selector.selector({"text": {}}), + } +).extend(OPTIONS_SCHEMA.schema) + +CONFIG_FLOW = {"user": HelperFlowStep(CONFIG_SCHEMA)} + +OPTIONS_FLOW = {"init": HelperFlowStep(OPTIONS_SCHEMA)} + + +class ConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for NEW_NAME.""" + + config_flow = CONFIG_FLOW + # TODO remove the options_flow if the integration does not have an options flow + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) if "name" in options else "" diff --git a/script/scaffold/templates/config_flow_helper/integration/const.py b/script/scaffold/templates/config_flow_helper/integration/const.py new file mode 100644 index 00000000000..e8a1c494d49 --- /dev/null +++ b/script/scaffold/templates/config_flow_helper/integration/const.py @@ -0,0 +1,3 @@ +"""Constants for the NEW_NAME integration.""" + +DOMAIN = "NEW_DOMAIN" diff --git a/script/scaffold/templates/config_flow_helper/integration/sensor.py b/script/scaffold/templates/config_flow_helper/integration/sensor.py new file mode 100644 index 00000000000..fbf92bfdd6b --- /dev/null +++ b/script/scaffold/templates/config_flow_helper/integration/sensor.py @@ -0,0 +1,38 @@ +"""Sensor platform for NEW_NAME integration.""" +from __future__ import annotations + +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize NEW_NAME config entry.""" + registry = er.async_get(hass) + # Validate + resolve entity registry id to entity_id + entity_id = er.async_validate_entity_id( + registry, config_entry.options[CONF_ENTITY_ID] + ) + # TODO Optionally validate config entry options before creating entity + name = config_entry.title + unique_id = config_entry.entry_id + + async_add_entities([NEW_DOMAINSensorEntity(unique_id, name, entity_id)]) + + +class NEW_DOMAINSensorEntity(SensorEntity): + """NEW_DOMAIN Sensor.""" + + def __init__(self, unique_id: str, name: str, wrapped_entity_id: str) -> None: + """Initialize NEW_DOMAIN Sensor.""" + super().__init__() + self._wrapped_entity_id = wrapped_entity_id + self._attr_name = name + self._attr_unique_id = unique_id diff --git a/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py new file mode 100644 index 00000000000..b7bef2c63f4 --- /dev/null +++ b/script/scaffold/templates/config_flow_helper/tests/test_config_flow.py @@ -0,0 +1,116 @@ +"""Test the NEW_NAME config flow.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("platform", ("sensor",)) +async def test_config_flow(hass: HomeAssistant, platform) -> None: + """Test the config flow.""" + input_sensor_entity_id = "sensor.input" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.NEW_DOMAIN.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"name": "My NEW_DOMAIN", "entity_id": input_sensor_entity_id}, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My NEW_DOMAIN" + assert result["data"] == {} + assert result["options"] == { + "entity_id": input_sensor_entity_id, + "name": "My NEW_DOMAIN", + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "entity_id": input_sensor_entity_id, + "name": "My NEW_DOMAIN", + } + assert config_entry.title == "My NEW_DOMAIN" + + +def get_suggested(schema, key): + """Get suggested value for key in voluptuous schema.""" + for k in schema.keys(): + if k == key: + if k.description is None or "suggested_value" not in k.description: + return None + return k.description["suggested_value"] + # Wanted key absent from schema + raise Exception + + +@pytest.mark.parametrize("platform", ("sensor",)) +async def test_options(hass: HomeAssistant, platform) -> None: + """Test reconfiguring.""" + input_sensor_1_entity_id = "sensor.input1" + input_sensor_2_entity_id = "sensor.input2" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": input_sensor_1_entity_id, + "name": "My NEW_DOMAIN", + }, + title="My NEW_DOMAIN", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert get_suggested(schema, "entity_id") == input_sensor_1_entity_id + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "entity_id": input_sensor_2_entity_id, + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + "entity_id": input_sensor_2_entity_id, + "name": "My NEW_DOMAIN", + } + assert config_entry.data == {} + assert config_entry.options == { + "entity_id": input_sensor_2_entity_id, + "name": "My NEW_DOMAIN", + } + assert config_entry.title == "My NEW_DOMAIN" + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 1 + + # TODO Check the state of the entity has changed as expected + state = hass.states.get(f"{platform}.my_NEW_DOMAIN") + assert state.attributes == {} diff --git a/script/scaffold/templates/config_flow_helper/tests/test_init.py b/script/scaffold/templates/config_flow_helper/tests/test_init.py new file mode 100644 index 00000000000..0e86874c745 --- /dev/null +++ b/script/scaffold/templates/config_flow_helper/tests/test_init.py @@ -0,0 +1,50 @@ +"""Test the NEW_NAME integration.""" +import pytest + +from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("platform", ("sensor",)) +async def test_setup_and_remove_config_entry( + hass: HomeAssistant, + platform: str, +) -> None: + """Test setting up and removing a config entry.""" + input_sensor_entity_id = "sensor.input" + registry = er.async_get(hass) + NEW_DOMAIN_entity_id = f"{platform}.my_NEW_DOMAIN" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": input_sensor_entity_id, + "name": "My NEW_DOMAIN", + }, + title="My NEW_DOMAIN", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the entity is registered in the entity registry + assert registry.async_get(NEW_DOMAIN_entity_id) is not None + + # Check the platform is setup correctly + state = hass.states.get(NEW_DOMAIN_entity_id) + # TODO Check the state of the entity has changed as expected + assert state.state == "unknown" + assert state.attributes == {} + + # Remove the config entry + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are removed + assert hass.states.get(NEW_DOMAIN_entity_id) is None + assert registry.async_get(NEW_DOMAIN_entity_id) is None