-
-
Notifications
You must be signed in to change notification settings - Fork 31k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add config flow to Yamaha #131246
base: dev
Are you sure you want to change the base?
Add config flow to Yamaha #131246
Changes from all commits
ad66426
fb441fe
02e611b
f852ee3
cd9030e
99bf77b
83f0ee5
e1edf38
dd12dde
00301c3
8370290
7a8f0bd
4899ece
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,72 @@ | ||
"""The yamaha component.""" | ||
|
||
from __future__ import annotations | ||
|
||
from functools import partial | ||
import logging | ||
|
||
import rxv | ||
|
||
from homeassistant.components import ssdp | ||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.const import CONF_HOST, Platform | ||
from homeassistant.core import HomeAssistant | ||
|
||
from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN | ||
from .yamaha_config_info import YamahaConfigInfo | ||
|
||
PLATFORMS = [Platform.MEDIA_PLAYER] | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
async def get_upnp_desc(hass: HomeAssistant, host: str) -> str: | ||
"""Get the upnp description URL for a given host, using the SSPD scanner.""" | ||
ssdp_entries = await ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") | ||
matches = [w for w in ssdp_entries if w.ssdp_headers.get("_host", "") == host] | ||
upnp_desc = None | ||
for match in matches: | ||
if upnp_desc := match.ssdp_location: | ||
break | ||
|
||
if not upnp_desc: | ||
_LOGGER.warning( | ||
"The upnp_description was not found automatically, setting a default one" | ||
) | ||
upnp_desc = f"http://{host}:49154/MediaRenderer/desc.xml" | ||
return upnp_desc | ||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Set up Yamaha from a config entry.""" | ||
if entry.data.get(CONF_UPNP_DESC) is None: | ||
hass.config_entries.async_update_entry( | ||
entry, | ||
data={ | ||
CONF_HOST: entry.data[CONF_HOST], | ||
CONF_SERIAL: entry.data[CONF_SERIAL], | ||
CONF_UPNP_DESC: await get_upnp_desc(hass, entry.data[CONF_HOST]), | ||
}, | ||
) | ||
|
||
hass.data.setdefault(DOMAIN, {}) | ||
rxv_details = await YamahaConfigInfo.get_rxv_details( | ||
entry.data[CONF_UPNP_DESC], hass | ||
) | ||
entry.runtime_data = await hass.async_add_executor_job( | ||
partial(rxv.RXV, **rxv_details._asdict()) # type: ignore[union-attr] | ||
) | ||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
|
||
entry.async_on_unload(entry.add_update_listener(async_reload_entry)) | ||
return True | ||
|
||
|
||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Unload a config entry.""" | ||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
|
||
|
||
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: | ||
"""Reload config entry.""" | ||
await hass.config_entries.async_reload(entry.entry_id) | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,238 @@ | ||
"""Config flow for Yamaha.""" | ||
|
||
from __future__ import annotations | ||
|
||
import logging | ||
from typing import Any | ||
from urllib.parse import urlparse | ||
|
||
from requests.exceptions import ConnectionError | ||
import rxv | ||
import voluptuous as vol | ||
|
||
from homeassistant import data_entry_flow | ||
from homeassistant.components import ssdp | ||
from homeassistant.config_entries import ( | ||
ConfigEntry, | ||
ConfigFlow, | ||
ConfigFlowResult, | ||
OptionsFlow, | ||
) | ||
from homeassistant.const import CONF_HOST | ||
from homeassistant.core import callback | ||
from homeassistant.helpers.selector import ( | ||
SelectOptionDict, | ||
Selector, | ||
SelectSelector, | ||
SelectSelectorConfig, | ||
SelectSelectorMode, | ||
TextSelector, | ||
) | ||
|
||
from . import get_upnp_desc | ||
from .const import ( | ||
CONF_SERIAL, | ||
CONF_UPNP_DESC, | ||
DEFAULT_NAME, | ||
DOMAIN, | ||
OPTION_INPUT_SOURCES, | ||
OPTION_INPUT_SOURCES_IGNORE, | ||
) | ||
from .yamaha_config_info import YamahaConfigInfo | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
class YamahaFlowHandler(ConfigFlow, domain=DOMAIN): | ||
"""Handle a Yamaha config flow.""" | ||
|
||
VERSION = 1 | ||
|
||
serial_number: str | None = None | ||
host: str | ||
upnp_description: str | None = None | ||
|
||
async def async_step_user( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> ConfigFlowResult: | ||
"""Handle a flow initiated by the user.""" | ||
# Request user input, unless we are preparing discovery flow | ||
if user_input is None: | ||
return self._show_setup_form() | ||
|
||
host = user_input[CONF_HOST] | ||
serial_number = None | ||
|
||
# Check if device is a Yamaha receiver | ||
try: | ||
upnp_desc: str = await get_upnp_desc(self.hass, host) | ||
info = await YamahaConfigInfo.get_rxv_details(upnp_desc, self.hass) | ||
except ConnectionError: | ||
return self.async_abort(reason="cannot_connect") | ||
except Exception: | ||
_LOGGER.exception("Unexpected exception") | ||
return self.async_abort(reason="unknown") | ||
else: | ||
if info is None or (serial_number := info.serial_number) is None: | ||
return self.async_abort(reason="cannot_connect") | ||
|
||
await self.async_set_unique_id(serial_number, raise_on_progress=False) | ||
self._abort_if_unique_id_configured() | ||
|
||
return self.async_create_entry( | ||
title=DEFAULT_NAME, | ||
data={ | ||
CONF_HOST: host, | ||
CONF_SERIAL: serial_number, | ||
CONF_UPNP_DESC: await get_upnp_desc(self.hass, host), | ||
}, | ||
options={ | ||
OPTION_INPUT_SOURCES_IGNORE: user_input.get(OPTION_INPUT_SOURCES_IGNORE) | ||
or [], | ||
OPTION_INPUT_SOURCES: user_input.get(OPTION_INPUT_SOURCES) or {}, | ||
}, | ||
) | ||
|
||
def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: | ||
"""Show the setup form to the user.""" | ||
return self.async_show_form( | ||
step_id="user", | ||
data_schema=vol.Schema({vol.Required(CONF_HOST): str}), | ||
errors=errors or {}, | ||
) | ||
|
||
async def async_step_ssdp( | ||
self, discovery_info: ssdp.SsdpServiceInfo | ||
) -> ConfigFlowResult: | ||
"""Handle ssdp discoveries.""" | ||
assert discovery_info.ssdp_location is not None | ||
if not await YamahaConfigInfo.check_yamaha_ssdp( | ||
discovery_info.ssdp_location, self.hass | ||
): | ||
return self.async_abort(reason="yxc_control_url_missing") | ||
self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] | ||
self.upnp_description = discovery_info.ssdp_location | ||
|
||
# ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment | ||
self.host = urlparse(discovery_info.ssdp_location).hostname # type: ignore[assignment] | ||
|
||
await self.async_set_unique_id(self.serial_number) | ||
self._abort_if_unique_id_configured( | ||
{ | ||
CONF_HOST: self.host, | ||
CONF_UPNP_DESC: self.upnp_description, | ||
} | ||
) | ||
self.context.update( | ||
{ | ||
"title_placeholders": { | ||
"name": discovery_info.upnp.get( | ||
ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host | ||
) | ||
} | ||
} | ||
) | ||
|
||
return await self.async_step_confirm() | ||
|
||
async def async_step_confirm( | ||
self, user_input=None | ||
) -> data_entry_flow.ConfigFlowResult: | ||
"""Allow the user to confirm adding the device.""" | ||
if user_input is not None: | ||
return self.async_create_entry( | ||
title=DEFAULT_NAME, | ||
data={ | ||
CONF_HOST: self.host, | ||
CONF_SERIAL: self.serial_number, | ||
CONF_UPNP_DESC: self.upnp_description, | ||
}, | ||
options={ | ||
OPTION_INPUT_SOURCES_IGNORE: user_input.get( | ||
OPTION_INPUT_SOURCES_IGNORE | ||
) | ||
or [], | ||
OPTION_INPUT_SOURCES: user_input.get(OPTION_INPUT_SOURCES) or {}, | ||
}, | ||
) | ||
|
||
return self.async_show_form(step_id="confirm") | ||
|
||
async def async_step_import(self, import_data: dict) -> ConfigFlowResult: | ||
"""Import data from configuration.yaml into the config flow.""" | ||
return await self.async_step_user(import_data) | ||
|
||
@staticmethod | ||
@callback | ||
def async_get_options_flow( | ||
config_entry: ConfigEntry, | ||
) -> OptionsFlow: | ||
"""Return the options flow.""" | ||
return YamahaOptionsFlowHandler(config_entry) | ||
|
||
|
||
class YamahaOptionsFlowHandler(OptionsFlow): | ||
"""Handle an options flow for Yamaha.""" | ||
|
||
def __init__(self, config_entry: ConfigEntry) -> None: | ||
"""Initialize options flow.""" | ||
self._input_sources_ignore: list[str] = config_entry.options[ | ||
OPTION_INPUT_SOURCES_IGNORE | ||
] | ||
self._input_sources: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] | ||
|
||
async def async_step_init( | ||
self, user_input: dict[str, Any] | None = None | ||
) -> ConfigFlowResult: | ||
"""Manage the options.""" | ||
yamaha: rxv.RXV = self.config_entry.runtime_data | ||
inputs: dict[str, str] = await self.hass.async_add_executor_job(yamaha.inputs) | ||
|
||
if user_input is not None: | ||
sources_store: dict[str, str] = { | ||
k: v for k, v in user_input.items() if k in inputs and v != "" | ||
} | ||
|
||
return self.async_create_entry( | ||
data={ | ||
OPTION_INPUT_SOURCES: sources_store, | ||
OPTION_INPUT_SOURCES_IGNORE: user_input.get( | ||
OPTION_INPUT_SOURCES_IGNORE | ||
), | ||
Comment on lines
+199
to
+201
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For the ignore one, I believe there is a way on the device to limit the sources, can explore that option? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I checked my receiver (RX-A2010) but didn't find a way to ignore inputs. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here not on my device (RX2020) there is no functionality to support it in the rxv lib. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It also isn't supported by the integration in HACS via the device and that uses a different protocol / method than rxv. |
||
} | ||
) | ||
|
||
schema_dict: dict[Any, Selector] = {} | ||
available_inputs = [ | ||
SelectOptionDict(value=k, label=k) for k, v in inputs.items() | ||
] | ||
|
||
schema_dict[vol.Optional(OPTION_INPUT_SOURCES_IGNORE)] = SelectSelector( | ||
SelectSelectorConfig( | ||
options=available_inputs, | ||
mode=SelectSelectorMode.DROPDOWN, | ||
multiple=True, | ||
) | ||
) | ||
|
||
for source in inputs: | ||
if source not in self._input_sources_ignore: | ||
schema_dict[vol.Optional(source, default="")] = TextSelector() | ||
|
||
options = self.config_entry.options.copy() | ||
if OPTION_INPUT_SOURCES_IGNORE in self.config_entry.options: | ||
options[OPTION_INPUT_SOURCES_IGNORE] = self.config_entry.options[ | ||
OPTION_INPUT_SOURCES_IGNORE | ||
] | ||
if OPTION_INPUT_SOURCES in self.config_entry.options: | ||
for source, source_name in self.config_entry.options[ | ||
OPTION_INPUT_SOURCES | ||
].items(): | ||
options[source] = source_name | ||
|
||
return self.async_show_form( | ||
step_id="init", | ||
data_schema=self.add_suggested_values_to_schema( | ||
vol.Schema(schema_dict), options | ||
), | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Config flow.py is required to be 100% unit tested