diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 974b4082cae..228748d5257 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -15,6 +15,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback +from homeassistant.data_entry_flow import section from homeassistant.helpers.selector import ( NumberSelector, NumberSelectorConfig, @@ -49,9 +50,13 @@ INPUT_SOURCES_ALL_MEANINGS = [ input_source.value_meaning for input_source in InputSource ] STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) -STEP_CONFIGURE_SCHEMA = vol.Schema( +STEP_RECONFIGURE_SCHEMA = vol.Schema( { vol.Required(OPTION_VOLUME_RESOLUTION): vol.In(VOLUME_RESOLUTION_ALLOWED), + } +) +STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend( + { vol.Required(OPTION_INPUT_SOURCES): SelectSelector( SelectSelectorConfig( options=INPUT_SOURCES_ALL_MEANINGS, @@ -216,55 +221,52 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the configuration of a single receiver.""" errors = {} - entry = None - entry_options = None + reconfigure_entry = None + schema = STEP_CONFIGURE_SCHEMA if self.source == SOURCE_RECONFIGURE: - entry = self._get_reconfigure_entry() - entry_options = entry.options + schema = STEP_RECONFIGURE_SCHEMA + reconfigure_entry = self._get_reconfigure_entry() if user_input is not None: - source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] - if not source_meanings: + volume_resolution = user_input[OPTION_VOLUME_RESOLUTION] + + if reconfigure_entry is not None: + entry_options = reconfigure_entry.options + result = self.async_update_reload_and_abort( + reconfigure_entry, + data={ + CONF_HOST: self._receiver_info.host, + }, + options={ + OPTION_VOLUME_RESOLUTION: volume_resolution, + OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], + OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES], + }, + ) + + _LOGGER.debug("Reconfigured receiver, result: %s", result) + return result + + input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES] + if not input_source_meanings: errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" else: - sources_store: dict[str, str] = {} - for source_meaning in source_meanings: - source = InputSource.from_meaning(source_meaning) + input_sources_store: dict[str, str] = {} + for input_source_meaning in input_source_meanings: + input_source = InputSource.from_meaning(input_source_meaning) + input_sources_store[input_source.value] = input_source_meaning - source_name = source_meaning - if entry_options is not None: - source_name = entry_options[OPTION_INPUT_SOURCES].get( - source.value, source_name - ) - sources_store[source.value] = source_name - - volume_resolution = user_input[OPTION_VOLUME_RESOLUTION] - - if entry_options is None: - result = self.async_create_entry( - title=self._receiver_info.model_name, - data={ - CONF_HOST: self._receiver_info.host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, - OPTION_INPUT_SOURCES: sources_store, - }, - ) - else: - assert entry is not None - result = self.async_update_reload_and_abort( - entry, - data={ - CONF_HOST: self._receiver_info.host, - }, - options={ - OPTION_VOLUME_RESOLUTION: volume_resolution, - OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: sources_store, - }, - ) + result = self.async_create_entry( + title=self._receiver_info.model_name, + data={ + CONF_HOST: self._receiver_info.host, + }, + options={ + OPTION_VOLUME_RESOLUTION: volume_resolution, + OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT, + OPTION_INPUT_SOURCES: input_sources_store, + }, + ) _LOGGER.debug("Configured receiver, result: %s", result) return result @@ -273,12 +275,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): suggested_values = user_input if suggested_values is None: - if entry_options is None: + if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, OPTION_INPUT_SOURCES: [], } else: + entry_options = reconfigure_entry.options suggested_values = { OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], OPTION_INPUT_SOURCES: [ @@ -289,9 +292,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="configure_receiver", - data_schema=self.add_suggested_values_to_schema( - STEP_CONFIGURE_SCHEMA, suggested_values - ), + data_schema=self.add_suggested_values_to_schema(schema, suggested_values), errors=errors, description_placeholders={ "name": f"{self._receiver_info.model_name} ({self._receiver_info.host})" @@ -360,57 +361,107 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: """Return the options flow.""" - return OnkyoOptionsFlowHandler(config_entry) + return OnkyoOptionsFlowHandler() + + +OPTIONS_STEP_INIT_SCHEMA = vol.Schema( + { + vol.Required(OPTION_MAX_VOLUME): NumberSelector( + NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) + ), + vol.Required(OPTION_INPUT_SOURCES): SelectSelector( + SelectSelectorConfig( + options=INPUT_SOURCES_ALL_MEANINGS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } +) class OnkyoOptionsFlowHandler(OptionsFlow): """Handle an options flow for Onkyo.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - sources_store: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] - self._input_sources = {InputSource(k): v for k, v in sources_store.items()} + _data: dict[str, Any] + _input_sources: dict[InputSource, str] async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + errors = {} + + entry_options = self.config_entry.options + if user_input is not None: - sources_store: dict[str, str] = {} - for source_meaning, source_name in user_input.items(): - if source_meaning in INPUT_SOURCES_ALL_MEANINGS: - source = InputSource.from_meaning(source_meaning) - sources_store[source.value] = source_name + self._input_sources = {} + for input_source_meaning in user_input[OPTION_INPUT_SOURCES]: + input_source = InputSource.from_meaning(input_source_meaning) + input_source_name = entry_options[OPTION_INPUT_SOURCES].get( + input_source.value, input_source_meaning + ) + self._input_sources[input_source] = input_source_name + + if not self._input_sources: + errors[OPTION_INPUT_SOURCES] = "empty_input_source_list" + else: + self._data = { + OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION], + OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], + } + + return await self.async_step_names() + + suggested_values = user_input + if suggested_values is None: + suggested_values = { + OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], + OPTION_INPUT_SOURCES: [ + InputSource(input_source).value_meaning + for input_source in entry_options[OPTION_INPUT_SOURCES] + ], + } + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_STEP_INIT_SCHEMA, suggested_values + ), + errors=errors, + ) + + async def async_step_names( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure names.""" + if user_input is not None: + input_sources_store: dict[str, str] = {} + for input_source_meaning, input_source_name in user_input[ + "input_sources" + ].items(): + input_source = InputSource.from_meaning(input_source_meaning) + input_sources_store[input_source.value] = input_source_name return self.async_create_entry( data={ - OPTION_VOLUME_RESOLUTION: self.config_entry.options[ - OPTION_VOLUME_RESOLUTION - ], - OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME], - OPTION_INPUT_SOURCES: sources_store, + **self._data, + OPTION_INPUT_SOURCES: input_sources_store, } ) schema_dict: dict[Any, Selector] = {} - max_volume: float = self.config_entry.options[OPTION_MAX_VOLUME] - schema_dict[vol.Required(OPTION_MAX_VOLUME, default=max_volume)] = ( - NumberSelector( - NumberSelectorConfig(min=1, max=100, mode=NumberSelectorMode.BOX) - ) - ) - - for source, source_name in self._input_sources.items(): - schema_dict[vol.Required(source.value_meaning, default=source_name)] = ( - TextSelector() - ) + for input_source, input_source_name in self._input_sources.items(): + schema_dict[ + vol.Required(input_source.value_meaning, default=input_source_name) + ] = TextSelector() return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema_dict), + step_id="names", + data_schema=vol.Schema( + {vol.Required("input_sources"): section(vol.Schema(schema_dict))} + ), ) diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json index 849171c7161..b3b14efec44 100644 --- a/homeassistant/components/onkyo/strings.json +++ b/homeassistant/components/onkyo/strings.json @@ -27,17 +27,17 @@ "description": "Configure {name}", "data": { "volume_resolution": "Volume resolution", - "input_sources": "Input sources" + "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]" }, "data_description": { "volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.", - "input_sources": "List of input sources supported by the receiver." + "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "empty_input_source_list": "Input source list cannot be empty", + "empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { @@ -52,12 +52,25 @@ "step": { "init": { "data": { - "max_volume": "Maximum volume limit (%)" + "max_volume": "Maximum volume limit (%)", + "input_sources": "Input sources" }, "data_description": { - "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value." + "max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.", + "input_sources": "List of input sources supported by the receiver." + } + }, + "names": { + "sections": { + "input_sources": { + "name": "Input source names", + "description": "Mappings of receiver's input sources to their names." + } } } + }, + "error": { + "empty_input_source_list": "Input source list cannot be empty" } }, "issues": { diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 865bc1a6bbf..203cc22cf95 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -10,6 +10,7 @@ from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, + OPTION_INPUT_SOURCES, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, ) @@ -87,35 +88,6 @@ async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> assert host_result["errors"]["base"] == "cannot_connect" -async def test_ssdp_discovery_already_configured( - hass: HomeAssistant, default_mock_discovery -) -> None: - """Test SSDP discovery with already configured device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100"}, - unique_id="id1", - ) - config_entry.add_to_hass(hass) - - discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", - upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, - ssdp_usn="uuid:mock_usn", - ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", - ssdp_st="mock_st", - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=discovery_info, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_manual_valid_host_unexpected_error( hass: HomeAssistant, empty_mock_discovery ) -> None: @@ -262,6 +234,35 @@ async def test_ssdp_discovery_success( assert select_result["result"].unique_id == "id1" +async def test_ssdp_discovery_already_configured( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with already configured device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="id1", + ) + config_entry.add_to_hass(hass) + + discovery_info = SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: """Test SSDP discovery with host info error.""" discovery_info = SsdpServiceInfo( @@ -466,7 +467,7 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: await setup_integration(hass, config_entry, receiver_info) old_host = config_entry.data[CONF_HOST] - old_max_volume = config_entry.options[OPTION_MAX_VOLUME] + old_options = config_entry.options result = await config_entry.start_reconfigure_flow(hass) @@ -483,7 +484,7 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - user_input={"volume_resolution": 200, "input_sources": ["TUNER"]}, + user_input={OPTION_VOLUME_RESOLUTION: 200}, ) assert result3["type"] is FlowResultType.ABORT @@ -491,7 +492,10 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: assert config_entry.data[CONF_HOST] == old_host assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 - assert config_entry.options[OPTION_MAX_VOLUME] == old_max_volume + for option, option_value in old_options.items(): + if option == OPTION_VOLUME_RESOLUTION: + continue + assert config_entry.options[option] == option_value async def test_reconfigure_new_device(hass: HomeAssistant) -> None: @@ -610,8 +614,8 @@ async def test_import_success( "ignore_translations", [ [ # The schema is dynamically created from input sources - "component.onkyo.options.step.init.data.TV", - "component.onkyo.options.step.init.data_description.TV", + "component.onkyo.options.step.names.sections.input_sources.data.TV", + "component.onkyo.options.step.names.sections.input_sources.data_description.TV", ] ], ) @@ -622,23 +626,43 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) config_entry = create_empty_config_entry() await setup_integration(hass, config_entry, receiver_info) + old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] + result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - "max_volume": 42, - "TV": "television", + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: [], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"} + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_MAX_VOLUME: 42, + OPTION_INPUT_SOURCES: ["TV"], + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "names" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + OPTION_INPUT_SOURCES: {"TV": "television"}, }, ) - await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { - "volume_resolution": 80, - "max_volume": 42.0, - "input_sources": { - "12": "television", - }, + OPTION_VOLUME_RESOLUTION: old_volume_resolution, + OPTION_MAX_VOLUME: 42.0, + OPTION_INPUT_SOURCES: {"12": "television"}, }