Skip to content
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

Open
wants to merge 13 commits into
base: dev
Choose a base branch
from
Open
81 changes: 81 additions & 0 deletions homeassistant/components/yamaha/__init__.py
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(

Check warning on line 49 in homeassistant/components/yamaha/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/__init__.py#L48-L49

Added lines #L48 - L49 were not covered by tests
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)

Check warning on line 82 in homeassistant/components/yamaha/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/__init__.py#L82

Added line #L82 was not covered by tests
261 changes: 261 additions & 0 deletions homeassistant/components/yamaha/config_flow.py
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"
Copy link
Member

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?

Copy link
Contributor Author

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?

Copy link
Contributor Author

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

Copy link
Contributor

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.

Copy link
Contributor Author

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?


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(

Check warning on line 182 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L181-L182

Added lines #L181 - L182 were not covered by tests
"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)

Check warning on line 194 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L194

Added line #L194 was not covered by tests
Copy link
Member

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



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[

Check warning on line 202 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L202

Added line #L202 was not covered by tests
OPTION_INPUT_SOURCES_IGNORE
]
self._input_sources: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES]

Check warning on line 205 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L205

Added line #L205 was not covered by tests

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)

Check warning on line 212 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L211-L212

Added lines #L211 - L212 were not covered by tests

if user_input is not None:
sources_store: dict[str, str] = {

Check warning on line 215 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L214-L215

Added lines #L214 - L215 were not covered by tests
k: v for k, v in user_input.items() if k in inputs and v != ""
}

return self.async_create_entry(

Check warning on line 219 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L219

Added line #L219 was not covered by tests
data={
OPTION_INPUT_SOURCES: sources_store,
OPTION_INPUT_SOURCES_IGNORE: user_input.get(
OPTION_INPUT_SOURCES_IGNORE
),
Comment on lines +199 to +201
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.
I can rename inputs on the device but even those modified names do not get propagated outside the unit, the output of rxv.inputs() stays the same.

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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 = [

Check warning on line 229 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L228-L229

Added lines #L228 - L229 were not covered by tests
SelectOptionDict(value=k, label=k) for k, v in inputs.items()
]

schema_dict[vol.Optional(OPTION_INPUT_SOURCES_IGNORE)] = SelectSelector(

Check warning on line 233 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L233

Added line #L233 was not covered by tests
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()

Check warning on line 243 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L241-L243

Added lines #L241 - L243 were not covered by tests

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[

Check warning on line 247 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L245-L247

Added lines #L245 - L247 were not covered by tests
OPTION_INPUT_SOURCES_IGNORE
]
if OPTION_INPUT_SOURCES in self.config_entry.options:
for source, source_name in self.config_entry.options[

Check warning on line 251 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L250-L251

Added lines #L250 - L251 were not covered by tests
OPTION_INPUT_SOURCES
].items():
options[source] = source_name

Check warning on line 254 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L254

Added line #L254 was not covered by tests

return self.async_show_form(

Check warning on line 256 in homeassistant/components/yamaha/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/yamaha/config_flow.py#L256

Added line #L256 was not covered by tests
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(schema_dict), options
),
)
8 changes: 6 additions & 2 deletions homeassistant/components/yamaha/const.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Constants for the Yamaha component."""

DOMAIN = "yamaha"
DISCOVER_TIMEOUT = 3
KNOWN_ZONES = "known_zones"
BRAND = "Yamaha Corporation"
CONF_SERIAL = "serial"
CONF_MODEL = "model"
CURSOR_TYPE_DOWN = "down"
CURSOR_TYPE_LEFT = "left"
CURSOR_TYPE_RETURN = "return"
Expand All @@ -12,3 +13,6 @@
SERVICE_ENABLE_OUTPUT = "enable_output"
SERVICE_MENU_CURSOR = "menu_cursor"
SERVICE_SELECT_SCENE = "select_scene"
OPTION_INPUT_SOURCES = "source_names"
OPTION_INPUT_SOURCES_IGNORE = "source_ignore"
DEFAULT_NAME = "Yamaha Receiver"
12 changes: 11 additions & 1 deletion homeassistant/components/yamaha/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,18 @@
"domain": "yamaha",
"name": "Yamaha Network Receivers",
"codeowners": [],
"config_flow": true,
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/yamaha",
"iot_class": "local_polling",
"loggers": ["rxv"],
"requirements": ["rxv==0.7.0"]
"requirements": ["rxv==0.7.0"],
"ssdp": [
{
"manufacturer": "YAMAHA CORPORATION"
},
{
"manufacturer": "Yamaha Corporation"
}
]
}
Loading