diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py index 539dd980ffc..18c1a9a2d89 100644 --- a/homeassistant/components/braviatv/__init__.py +++ b/homeassistant/components/braviatv/__init__.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import CONF_IGNORED_SOURCES, DOMAIN +from .const import CONF_IGNORED_SOURCES, CONF_USE_PSK, DOMAIN from .coordinator import BraviaTVCoordinator PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER, Platform.REMOTE] @@ -22,13 +22,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b host = config_entry.data[CONF_HOST] mac = config_entry.data[CONF_MAC] pin = config_entry.data[CONF_PIN] + use_psk = config_entry.data.get(CONF_USE_PSK, False) ignored_sources = config_entry.options.get(CONF_IGNORED_SOURCES, []) session = async_create_clientsession( hass, cookie_jar=CookieJar(unsafe=True, quote_cookie=False) ) client = BraviaTV(host, mac, session=session) - coordinator = BraviaTVCoordinator(hass, client, pin, ignored_sources) + coordinator = BraviaTVCoordinator( + hass=hass, + client=client, + pin=pin, + use_psk=use_psk, + ignored_sources=ignored_sources, + ) config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/braviatv/config_flow.py b/homeassistant/components/braviatv/config_flow.py index 75a8d5873ef..e6bf5a44019 100644 --- a/homeassistant/components/braviatv/config_flow.py +++ b/homeassistant/components/braviatv/config_flow.py @@ -2,14 +2,16 @@ from __future__ import annotations from typing import Any +from urllib.parse import urlparse from aiohttp import CookieJar -from pybravia import BraviaTV, BraviaTVError, BraviaTVNotSupported +from pybravia import BraviaTV, BraviaTVAuthError, BraviaTVError, BraviaTVNotSupported import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PIN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_create_clientsession @@ -23,6 +25,7 @@ from .const import ( ATTR_MODEL, CLIENTID_PREFIX, CONF_IGNORED_SOURCES, + CONF_USE_PSK, DOMAIN, NICKNAME, ) @@ -33,10 +36,9 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - client: BraviaTV - def __init__(self) -> None: """Initialize config flow.""" + self.client: BraviaTV | None = None self.device_config: dict[str, Any] = {} @staticmethod @@ -45,11 +47,28 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Bravia TV options callback.""" return BraviaTVOptionsFlowHandler(config_entry) - async def async_init_device(self) -> FlowResult: - """Initialize and create Bravia TV device from config.""" - pin = self.device_config[CONF_PIN] + def create_client(self) -> None: + """Create Bravia TV client from config.""" + host = self.device_config[CONF_HOST] + session = async_create_clientsession( + self.hass, + cookie_jar=CookieJar(unsafe=True, quote_cookie=False), + ) + self.client = BraviaTV(host=host, session=session) - await self.client.connect(pin=pin, clientid=CLIENTID_PREFIX, nickname=NICKNAME) + async def async_create_device(self) -> FlowResult: + """Initialize and create Bravia TV device from config.""" + assert self.client + + pin = self.device_config[CONF_PIN] + use_psk = self.device_config[CONF_USE_PSK] + + if use_psk: + await self.client.connect(psk=pin) + else: + await self.client.connect( + pin=pin, clientid=CLIENTID_PREFIX, nickname=NICKNAME + ) await self.client.set_wol_mode(True) system_info = await self.client.get_system_info() @@ -72,13 +91,8 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: host = user_input[CONF_HOST] if is_host_valid(host): - session = async_create_clientsession( - self.hass, - cookie_jar=CookieJar(unsafe=True, quote_cookie=False), - ) - self.client = BraviaTV(host=host, session=session) self.device_config[CONF_HOST] = host - + self.create_client() return await self.async_step_authorize() errors[CONF_HOST] = "invalid_host" @@ -92,18 +106,23 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_authorize( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Get PIN from the Bravia TV device.""" + """Authorize Bravia TV device.""" errors: dict[str, str] = {} if user_input is not None: self.device_config[CONF_PIN] = user_input[CONF_PIN] + self.device_config[CONF_USE_PSK] = user_input[CONF_USE_PSK] try: - return await self.async_init_device() + return await self.async_create_device() + except BraviaTVAuthError: + errors["base"] = "invalid_auth" except BraviaTVNotSupported: errors["base"] = "unsupported_model" except BraviaTVError: errors["base"] = "cannot_connect" + assert self.client + try: await self.client.pair(CLIENTID_PREFIX, NICKNAME) except BraviaTVError: @@ -111,10 +130,53 @@ class BraviaTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="authorize", - data_schema=vol.Schema({vol.Required(CONF_PIN, default=""): str}), + data_schema=vol.Schema( + { + vol.Required(CONF_PIN, default=""): str, + vol.Required(CONF_USE_PSK, default=False): bool, + } + ), errors=errors, ) + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: + """Handle a discovered device.""" + parsed_url = urlparse(discovery_info.ssdp_location) + host = parsed_url.hostname + + await self.async_set_unique_id(discovery_info.upnp[ssdp.ATTR_UPNP_UDN]) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + self._async_abort_entries_match({CONF_HOST: host}) + + scalarweb_info = discovery_info.upnp["X_ScalarWebAPI_DeviceInfo"] + service_types = scalarweb_info["X_ScalarWebAPI_ServiceList"][ + "X_ScalarWebAPI_ServiceType" + ] + + if "videoScreen" not in service_types: + return self.async_abort(reason="not_bravia_device") + + model_name = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] + + self.context["title_placeholders"] = { + CONF_NAME: f"{model_name} ({friendly_name})", + CONF_HOST: host, + } + + self.device_config[CONF_HOST] = host + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + self.create_client() + return await self.async_step_authorize() + + return self.async_show_form(step_id="confirm") + class BraviaTVOptionsFlowHandler(config_entries.OptionsFlow): """Config flow options for Bravia TV.""" diff --git a/homeassistant/components/braviatv/const.py b/homeassistant/components/braviatv/const.py index 6ed8efd3739..8855499914c 100644 --- a/homeassistant/components/braviatv/const.py +++ b/homeassistant/components/braviatv/const.py @@ -9,6 +9,7 @@ ATTR_MANUFACTURER: Final = "Sony" ATTR_MODEL: Final = "model" CONF_IGNORED_SOURCES: Final = "ignored_sources" +CONF_USE_PSK: Final = "use_psk" CLIENTID_PREFIX: Final = "HomeAssistant" DOMAIN: Final = "braviatv" diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index d190c00b1c0..1f134d8e2de 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -59,12 +59,14 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): hass: HomeAssistant, client: BraviaTV, pin: str, + use_psk: bool, ignored_sources: list[str], ) -> None: """Initialize Bravia TV Client.""" self.client = client self.pin = pin + self.use_psk = use_psk self.ignored_sources = ignored_sources self.source: str | None = None self.source_list: list[str] = [] @@ -110,9 +112,12 @@ class BraviaTVCoordinator(DataUpdateCoordinator[None]): """Connect and fetch data.""" try: if not self.connected: - await self.client.connect( - pin=self.pin, clientid=CLIENTID_PREFIX, nickname=NICKNAME - ) + if self.use_psk: + await self.client.connect(psk=self.pin) + else: + await self.client.connect( + pin=self.pin, clientid=CLIENTID_PREFIX, nickname=NICKNAME + ) self.connected = True power_status = await self.client.get_power_status() diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index dca9d65cff0..556643d7856 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -4,6 +4,12 @@ "documentation": "https://www.home-assistant.io/integrations/braviatv", "requirements": ["pybravia==0.2.2"], "codeowners": ["@bieniu", "@Drafteed"], + "ssdp": [ + { + "st": "urn:schemas-sony-com:service:ScalarWebAPI:1", + "manufacturer": "Sony Corporation" + } + ], "config_flow": true, "iot_class": "local_polling", "loggers": ["pybravia"] diff --git a/homeassistant/components/braviatv/strings.json b/homeassistant/components/braviatv/strings.json index c00b143a442..f6c35f2b8ca 100644 --- a/homeassistant/components/braviatv/strings.json +++ b/homeassistant/components/braviatv/strings.json @@ -9,20 +9,26 @@ }, "authorize": { "title": "Authorize Sony Bravia TV", - "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Unregister remote device.", + "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device. \n\nYou can use PSK (Pre-Shared-Key) instead of PIN. PSK is a user-defined secret key used for access control. This authentication method is recommended as more stable. To enable PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Then check «Use PSK authentication» box and enter your PSK instead of PIN.", "data": { - "pin": "[%key:common::config_flow::data::pin%]" + "pin": "[%key:common::config_flow::data::pin%]", + "use_psk": "Use PSK authentication" } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" } }, "error": { "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unsupported_model": "Your TV model is not supported." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "no_ip_control": "IP Control is disabled on your TV or the TV is not supported." + "no_ip_control": "IP Control is disabled on your TV or the TV is not supported.", + "not_bravia_device": "The device is not a Bravia TV." } }, "options": { diff --git a/homeassistant/components/braviatv/translations/en.json b/homeassistant/components/braviatv/translations/en.json index 45722d13b9a..7401fda7324 100644 --- a/homeassistant/components/braviatv/translations/en.json +++ b/homeassistant/components/braviatv/translations/en.json @@ -2,21 +2,27 @@ "config": { "abort": { "already_configured": "Device is already configured", - "no_ip_control": "IP Control is disabled on your TV or the TV is not supported." + "no_ip_control": "IP Control is disabled on your TV or the TV is not supported.", + "not_bravia_device": "The device is not a Bravia TV." }, "error": { "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", "invalid_host": "Invalid hostname or IP address", "unsupported_model": "Your TV model is not supported." }, "step": { "authorize": { "data": { - "pin": "PIN Code" + "pin": "PIN Code", + "use_psk": "Use PSK authentication" }, - "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Unregister remote device.", + "description": "Enter the PIN code shown on the Sony Bravia TV. \n\nIf the PIN code is not shown, you have to unregister Home Assistant on your TV, go to: Settings -> Network -> Remote device settings -> Deregister remote device. \n\nYou can use PSK (Pre-Shared-Key) instead of PIN. PSK is a user-defined secret key used for access control. This authentication method is recommended as more stable. To enable PSK on your TV, go to: Settings -> Network -> Home Network Setup -> IP Control. Then check \u00abUse PSK authentication\u00bb box and enter your PSK instead of PIN.", "title": "Authorize Sony Bravia TV" }, + "confirm": { + "description": "Do you want to start set up?" + }, "user": { "data": { "host": "Host" diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 029afcd64fe..d7a67f579d8 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -15,6 +15,12 @@ SSDP = { "manufacturer": "AXIS", }, ], + "braviatv": [ + { + "manufacturer": "Sony Corporation", + "st": "urn:schemas-sony-com:service:ScalarWebAPI:1" + } + ], "control4": [ { "st": "c4:director", diff --git a/tests/components/braviatv/test_config_flow.py b/tests/components/braviatv/test_config_flow.py index a105f20d3ee..64986e9d973 100644 --- a/tests/components/braviatv/test_config_flow.py +++ b/tests/components/braviatv/test_config_flow.py @@ -1,11 +1,16 @@ """Define tests for the Bravia TV config flow.""" from unittest.mock import patch -from pybravia import BraviaTVConnectionError, BraviaTVNotSupported +from pybravia import BraviaTVAuthError, BraviaTVConnectionError, BraviaTVNotSupported from homeassistant import data_entry_flow -from homeassistant.components.braviatv.const import CONF_IGNORED_SOURCES, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.components import ssdp +from homeassistant.components.braviatv.const import ( + CONF_IGNORED_SOURCES, + CONF_USE_PSK, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PIN from tests.common import MockConfigEntry @@ -31,6 +36,44 @@ BRAVIA_SOURCES = [ {"title": "AV/Component", "uri": "extInput:component?port=1"}, ] +BRAVIA_SSDP = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://bravia-host:52323/dmr.xml", + upnp={ + ssdp.ATTR_UPNP_UDN: "uuid:1234", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Living TV", + ssdp.ATTR_UPNP_MODEL_NAME: "KE-55XH9096", + "X_ScalarWebAPI_DeviceInfo": { + "X_ScalarWebAPI_ServiceList": { + "X_ScalarWebAPI_ServiceType": [ + "guide", + "system", + "audio", + "avContent", + "videoScreen", + ], + }, + }, + }, +) + +FAKE_BRAVIA_SSDP = ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://soundbar-host:52323/dmr.xml", + upnp={ + ssdp.ATTR_UPNP_UDN: "uuid:1234", + ssdp.ATTR_UPNP_FRIENDLY_NAME: "Sony Audio Device", + ssdp.ATTR_UPNP_MODEL_NAME: "HT-S700RF", + "X_ScalarWebAPI_DeviceInfo": { + "X_ScalarWebAPI_ServiceList": { + "X_ScalarWebAPI_ServiceType": ["guide", "system", "audio", "avContent"], + }, + }, + }, +) + async def test_show_form(hass): """Test that the form is served with no input.""" @@ -42,6 +85,83 @@ async def test_show_form(hass): assert result["step_id"] == SOURCE_USER +async def test_ssdp_discovery(hass): + """Test that the device is discovered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=BRAVIA_SSDP, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "confirm" + + with patch("pybravia.BraviaTV.connect"), patch("pybravia.BraviaTV.pair"), patch( + "pybravia.BraviaTV.set_wol_mode" + ), patch( + "pybravia.BraviaTV.get_system_info", + return_value=BRAVIA_SYSTEM_INFO, + ), patch( + "homeassistant.components.braviatv.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "authorize" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PIN: "1234", CONF_USE_PSK: False} + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "very_unique_string" + assert result["title"] == "TV-Model" + assert result["data"] == { + CONF_HOST: "bravia-host", + CONF_PIN: "1234", + CONF_USE_PSK: False, + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + +async def test_ssdp_discovery_fake(hass): + """Test that not Bravia device is not discovered.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=FAKE_BRAVIA_SSDP, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "not_bravia_device" + + +async def test_ssdp_discovery_exist(hass): + """Test that the existed device is not discovered.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="very_unique_string", + data={ + CONF_HOST: "bravia-host", + CONF_PIN: "1234", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + }, + title="TV-Model", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_SSDP}, + data=BRAVIA_SSDP, + ) + + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_user_invalid_host(hass): """Test that errors are shown when the host is invalid.""" result = await hass.config_entries.flow.async_init( @@ -51,6 +171,22 @@ async def test_user_invalid_host(hass): assert result["errors"] == {CONF_HOST: "invalid_host"} +async def test_authorize_invalid_auth(hass): + """Test that authorization errors shown on the authorization step.""" + with patch( + "pybravia.BraviaTV.connect", + side_effect=BraviaTVAuthError, + ), patch("pybravia.BraviaTV.pair"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PIN: "1234"} + ) + + assert result["errors"] == {"base": "invalid_auth"} + + async def test_authorize_cannot_connect(hass): """Test that errors are shown when cannot connect to host at the authorize step.""" with patch( @@ -114,7 +250,6 @@ async def test_duplicate_error(hass): "pybravia.BraviaTV.get_system_info", return_value=BRAVIA_SYSTEM_INFO, ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} ) @@ -136,7 +271,6 @@ async def test_create_entry(hass): ), patch( "homeassistant.components.braviatv.async_setup_entry", return_value=True ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} ) @@ -145,7 +279,7 @@ async def test_create_entry(hass): assert result["step_id"] == "authorize" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_PIN: "1234"} + result["flow_id"], user_input={CONF_PIN: "1234", CONF_USE_PSK: False} ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -154,6 +288,7 @@ async def test_create_entry(hass): assert result["data"] == { CONF_HOST: "bravia-host", CONF_PIN: "1234", + CONF_USE_PSK: False, CONF_MAC: "AA:BB:CC:DD:EE:FF", } @@ -168,7 +303,6 @@ async def test_create_entry_with_ipv6_address(hass): ), patch( "homeassistant.components.braviatv.async_setup_entry", return_value=True ): - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -179,7 +313,7 @@ async def test_create_entry_with_ipv6_address(hass): assert result["step_id"] == "authorize" result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={CONF_PIN: "1234"} + result["flow_id"], user_input={CONF_PIN: "1234", CONF_USE_PSK: False} ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -188,6 +322,39 @@ async def test_create_entry_with_ipv6_address(hass): assert result["data"] == { CONF_HOST: "2001:db8::1428:57ab", CONF_PIN: "1234", + CONF_USE_PSK: False, + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + +async def test_create_entry_psk(hass): + """Test that the user step works with PSK auth.""" + with patch("pybravia.BraviaTV.connect"), patch("pybravia.BraviaTV.pair"), patch( + "pybravia.BraviaTV.set_wol_mode" + ), patch( + "pybravia.BraviaTV.get_system_info", + return_value=BRAVIA_SYSTEM_INFO, + ), patch( + "homeassistant.components.braviatv.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={CONF_HOST: "bravia-host"} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "authorize" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PIN: "mypsk", CONF_USE_PSK: True} + ) + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == "very_unique_string" + assert result["title"] == "TV-Model" + assert result["data"] == { + CONF_HOST: "bravia-host", + CONF_PIN: "mypsk", + CONF_USE_PSK: True, CONF_MAC: "AA:BB:CC:DD:EE:FF", }