-
-
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 5 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,82 @@ | ||
"""The yamaha component.""" | ||
|
||
from __future__ import annotations | ||
|
||
import logging | ||
|
||
import rxv | ||
|
||
from homeassistant.components import ssdp | ||
from homeassistant.config_entries import ConfigEntry | ||
from homeassistant.const import CONF_HOST, CONF_NAME, Platform | ||
from homeassistant.core import HomeAssistant | ||
from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
|
||
from .const import CONF_MODEL, CONF_SERIAL, DOMAIN | ||
from .yamaha_config_info import YamahaConfigInfo | ||
|
||
PLATFORMS = [Platform.MEDIA_PLAYER] | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
|
||
async def get_upnp_serial_and_model(hass: HomeAssistant, host: str): | ||
"""Get the upnp serial and model 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_serial = None | ||
model = None | ||
for match in matches: | ||
if match.ssdp_location: | ||
upnp_serial = match.upnp[ssdp.ATTR_UPNP_SERIAL] | ||
model = match.upnp[ssdp.ATTR_UPNP_MODEL_NAME] | ||
break | ||
|
||
if upnp_serial is None: | ||
_LOGGER.warning( | ||
"Could not find serial from SSDP, attempting to retrieve serial from SSDP description URL" | ||
) | ||
upnp_serial, model = await YamahaConfigInfo.get_upnp_serial_and_model( | ||
host, async_get_clientsession(hass) | ||
) | ||
return upnp_serial, model | ||
|
||
|
||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
"""Set up Yamaha from a config entry.""" | ||
if entry.data.get(CONF_NAME) is None: | ||
upnp, model = await get_upnp_serial_and_model(hass, entry.data[CONF_HOST]) | ||
hass.config_entries.async_update_entry( | ||
entry, | ||
data={ | ||
CONF_HOST: entry.data[CONF_HOST], | ||
CONF_SERIAL: entry.data[CONF_SERIAL], | ||
CONF_NAME: upnp[ssdp.ATTR_UPNP_MODEL_NAME], | ||
CONF_MODEL: entry.data[CONF_MODEL], | ||
}, | ||
) | ||
|
||
hass.data.setdefault(DOMAIN, {}) | ||
info = YamahaConfigInfo(entry.data[CONF_HOST]) | ||
hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( | ||
rxv.RXV, info.ctrl_url, entry.data[CONF_MODEL], entry.data[CONF_SERIAL] | ||
) | ||
|
||
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.""" | ||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
if unload_ok: | ||
hass.data[DOMAIN].pop(entry.entry_id) | ||
|
||
return unload_ok | ||
|
||
|
||
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,261 @@ | ||
"""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, CONF_NAME | ||
from homeassistant.core import callback | ||
from homeassistant.data_entry_flow import FlowResultType | ||
from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
from homeassistant.helpers.selector import ( | ||
SelectOptionDict, | ||
Selector, | ||
SelectSelector, | ||
SelectSelectorConfig, | ||
SelectSelectorMode, | ||
TextSelector, | ||
) | ||
|
||
from . import get_upnp_serial_and_model | ||
from .const import ( | ||
CONF_MODEL, | ||
CONF_SERIAL, | ||
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 | ||
model: str | None = None | ||
host: str | ||
|
||
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 | ||
model = None | ||
|
||
errors = {} | ||
# Check if device is a Yamaha receiver | ||
try: | ||
info = YamahaConfigInfo(host) | ||
await self.hass.async_add_executor_job(rxv.RXV, info.ctrl_url) | ||
serial_number, model = await get_upnp_serial_and_model(self.hass, host) | ||
except ConnectionError: | ||
errors["base"] = "cannot_connect" | ||
except Exception: | ||
_LOGGER.exception("Unexpected exception") | ||
errors["base"] = "unknown" | ||
else: | ||
if serial_number is None: | ||
errors["base"] = "no_yamaha_device" | ||
|
||
if not errors: | ||
await self.async_set_unique_id(serial_number, raise_on_progress=False) | ||
self._abort_if_unique_id_configured() | ||
|
||
return self.async_create_entry( | ||
title=model or DEFAULT_NAME, | ||
data={ | ||
CONF_HOST: host, | ||
CONF_MODEL: model, | ||
CONF_SERIAL: serial_number, | ||
CONF_NAME: user_input.get(CONF_NAME) or DEFAULT_NAME, | ||
}, | ||
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._show_setup_form(errors) | ||
|
||
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, async_get_clientsession(self.hass) | ||
): | ||
return self.async_abort(reason="yxc_control_url_missing") | ||
self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] | ||
self.model = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] | ||
|
||
# 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_NAME: self.model, | ||
} | ||
) | ||
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=self.model or DEFAULT_NAME, | ||
data={ | ||
CONF_HOST: self.host, | ||
CONF_MODEL: self.model, | ||
CONF_SERIAL: self.serial_number, | ||
CONF_NAME: DEFAULT_NAME, | ||
}, | ||
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.""" | ||
res = await self.async_step_user(import_data) | ||
if res["type"] == FlowResultType.CREATE_ENTRY: | ||
_LOGGER.info( | ||
"Successfully imported %s from configuration.yaml", | ||
import_data.get(CONF_HOST), | ||
) | ||
elif res["type"] == FlowResultType.FORM: | ||
slyoldfox marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_LOGGER.error( | ||
"Could not import %s from configuration.yaml", | ||
import_data.get(CONF_HOST), | ||
) | ||
return res | ||
|
||
@staticmethod | ||
@callback | ||
def async_get_options_flow( | ||
config_entry: ConfigEntry, | ||
) -> OptionsFlow: | ||
"""Return the options flow.""" | ||
return YamahaOptionsFlowHandler(config_entry) | ||
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. Config flow.py is required to be 100% unit tested |
||
|
||
|
||
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 = self.hass.data[DOMAIN][self.config_entry.entry_id] | ||
inputs = 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.
Are there devices without serial number?
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.
I am unsure, I did assume there was going to be. I was doubting to add
CONF_HOST
as fallback as the unique_id but that would not accepted I read?How should I move forward with this?
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.
I'm gonna leave this for now, the implementation is the same as the Yamaha Musiccast integration now. I found some SSDP replies through github (https://github.com/foxthefox/yamaha-yxc-nodejs/blob/da9039e3edb1cc338b6f5c19710bbbfb6770b428/lib/data/ssdp_answer_WX30.xml#L17 and https://github.com/foxthefox/yamaha-yxc-nodejs/blob/da9039e3edb1cc338b6f5c19710bbbfb6770b428/lib/data/ssdp_answer_YSP1600.xml#L17).
As well as my RX-A2010
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.
@joostlek @slyoldfox yes from my experience there seem to be ssdp responses without serial in the wild, and the rxv lib doesn't support /cant get the serial post ssdp.
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.
@pssc does that include crawls from
http://IP/YamahaRemoteControl/desc.xml
?@joostlek how should I handle these?