diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 335f138ee7e..fcb41a5891b 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -180,14 +180,8 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._data = None self._apps = {} - async def _create_entry_if_unique( - self, input_dict: Dict[str, Any] - ) -> Dict[str, Any]: - """ - Create entry if ID is unique. - - If it is, create entry. If it isn't, abort config flow. - """ + async def _create_entry(self, input_dict: Dict[str, Any]) -> Dict[str, Any]: + """Create vizio config entry.""" # Remove extra keys that will not be used by entry setup input_dict.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE, None) input_dict.pop(CONF_INCLUDE_OR_EXCLUDE, None) @@ -206,19 +200,25 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: # Store current values in case setup fails and user needs to edit self._user_schema = _get_config_schema(user_input) + unique_id = await VizioAsync.get_unique_id( + user_input[CONF_HOST], + user_input[CONF_DEVICE_CLASS], + session=async_get_clientsession(self.hass, False), + ) - # Check if new config entry matches any existing config entries - for entry in self.hass.config_entries.async_entries(DOMAIN): - # If source is ignore bypass host and name check and continue through loop - if entry.source == SOURCE_IGNORE: - continue - if await self.hass.async_add_executor_job( - _host_is_same, entry.data[CONF_HOST], user_input[CONF_HOST] - ): - errors[CONF_HOST] = "host_exists" - - if entry.data[CONF_NAME] == user_input[CONF_NAME]: - errors[CONF_NAME] = "name_exists" + if not unique_id: + errors[CONF_HOST] = "cannot_connect" + else: + # Set unique ID and abort if a flow with the same unique ID is already in progress + existing_entry = await self.async_set_unique_id( + unique_id=unique_id, raise_on_progress=True + ) + # If device was discovered, abort if existing entry found, otherwise display an error + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + if self.context["source"] == SOURCE_ZEROCONF: + self._abort_if_unique_id_configured() + elif existing_entry: + errors[CONF_HOST] = "existing_config_entry_found" if not errors: # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 @@ -239,21 +239,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" if not errors: - unique_id = await VizioAsync.get_unique_id( - user_input[CONF_HOST], - user_input.get(CONF_ACCESS_TOKEN), - user_input[CONF_DEVICE_CLASS], - session=async_get_clientsession(self.hass, False), - ) - - # Set unique ID and abort if unique ID is already configured on an entry or a flow - # with the unique ID is already in progress - await self.async_set_unique_id( - unique_id=unique_id, raise_on_progress=True - ) - self._abort_if_unique_id_configured() - - return await self._create_entry_if_unique(user_input) + return await self._create_entry(user_input) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 elif self._must_show_form and self.context["source"] == SOURCE_IMPORT: # Import should always display the config form if CONF_ACCESS_TOKEN @@ -350,27 +336,10 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> Dict[str, Any]: """Handle zeroconf discovery.""" - # Set unique ID early to prevent device from getting rediscovered multiple times - await self.async_set_unique_id( - unique_id=discovery_info[CONF_HOST].split(":")[0], raise_on_progress=True - ) - self._abort_if_unique_id_configured() - discovery_info[ CONF_HOST ] = f"{discovery_info[CONF_HOST]}:{discovery_info[CONF_PORT]}" - # Check if new config entry matches any existing config entries and abort if so - for entry in self.hass.config_entries.async_entries(DOMAIN): - # If source is ignore bypass host check and continue through loop - if entry.source == SOURCE_IGNORE: - continue - - if await self.hass.async_add_executor_job( - _host_is_same, entry.data[CONF_HOST], discovery_info[CONF_HOST] - ): - return self.async_abort(reason="already_configured_device") - # Set default name to discovered device name by stripping zeroconf service # (`type`) from `name` num_chars_to_strip = len(discovery_info[CONF_TYPE]) + 1 @@ -436,20 +405,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token self._must_show_form = True - unique_id = await VizioAsync.get_unique_id( - self._data[CONF_HOST], - self._data[CONF_ACCESS_TOKEN], - self._data[CONF_DEVICE_CLASS], - session=async_get_clientsession(self.hass, False), - ) - - # Set unique ID and abort if unique ID is already configured on an entry or a flow - # with the unique ID is already in progress - await self.async_set_unique_id( - unique_id=unique_id, raise_on_progress=True - ) - self._abort_if_unique_id_configured() - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 if self.context["source"] == SOURCE_IMPORT: # If user is pairing via config import, show different message @@ -470,7 +425,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _pairing_complete(self, step_id: str) -> Dict[str, Any]: """Handle config flow completion.""" if not self._must_show_form: - return await self._create_entry_if_unique(self._data) + return await self._create_entry(self._data) self._must_show_form = False return self.async_show_form( diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 6223a95821c..a0c36dc0089 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -2,7 +2,7 @@ "domain": "vizio", "name": "VIZIO SmartCast", "documentation": "https://www.home-assistant.io/integrations/vizio", - "requirements": ["pyvizio==0.1.49"], + "requirements": ["pyvizio==0.1.51"], "codeowners": ["@raman325"], "config_flow": true, "zeroconf": ["_viziocast._tcp.local."], diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 408e37f011b..a2f67b1d5c8 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -294,7 +294,7 @@ class VizioDevice(MediaPlayerEntity): setting_type, setting_name, new_value, ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks when entity is added.""" # Register callback for when config entry is updated. self._async_unsub_listeners.append( @@ -310,7 +310,7 @@ class VizioDevice(MediaPlayerEntity): ) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect callbacks when entity is removed.""" for listener in self._async_unsub_listeners: listener() @@ -323,7 +323,7 @@ class VizioDevice(MediaPlayerEntity): return self._available @property - def state(self) -> str: + def state(self) -> Optional[str]: """Return the state of the device.""" return self._state @@ -338,7 +338,7 @@ class VizioDevice(MediaPlayerEntity): return self._icon @property - def volume_level(self) -> float: + def volume_level(self) -> Optional[float]: """Return the volume level of the device.""" return self._volume_level @@ -348,7 +348,7 @@ class VizioDevice(MediaPlayerEntity): return self._is_volume_muted @property - def source(self) -> str: + def source(self) -> Optional[str]: """Return current input of the device.""" if self._current_app is not None and self._current_input in INPUT_APPS: return self._current_app diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 0de1a380d12..8979f6fd82e 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -28,10 +28,9 @@ } }, "error": { - "host_exists": "[%key:component::vizio::config::step::user::title%] with specified host already configured.", - "name_exists": "[%key:component::vizio::config::step::user::title%] with specified name already configured.", "complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "existing_config_entry_found": "An existing [%key:component::vizio::config::step::user::title%] config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one." }, "abort": { "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/requirements_all.txt b/requirements_all.txt index 810fd65fab5..93cf481ec4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1834,7 +1834,7 @@ pyversasense==0.0.6 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.49 +pyvizio==0.1.51 # homeassistant.components.velux pyvlx==0.2.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee2f251428e..ed62aeee367 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -839,7 +839,7 @@ pyvera==0.3.9 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.49 +pyvizio==0.1.51 # homeassistant.components.volumio pyvolumio==0.1.1 diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index fd5ff7cd468..5daedf6fa57 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -47,15 +47,32 @@ def skip_notifications_fixture(): yield +@pytest.fixture(name="vizio_get_unique_id", autouse=True) +def vizio_get_unique_id_fixture(): + """Mock get vizio unique ID.""" + with patch( + "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id", + return_value=UNIQUE_ID, + ): + yield + + +@pytest.fixture(name="vizio_no_unique_id") +def vizio_no_unique_id_fixture(): + """Mock no vizio unique ID returrned.""" + with patch( + "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id", + return_value=None, + ): + yield + + @pytest.fixture(name="vizio_connect") def vizio_connect_fixture(): """Mock valid vizio device and entry setup.""" with patch( "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config", return_value=True, - ), patch( - "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id", - return_value=UNIQUE_ID, ): yield diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 74d4d6a2c62..41490e9f4be 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -51,7 +51,6 @@ from .const import ( NAME2, UNIQUE_ID, VOLUME_STEP, - ZEROCONF_HOST, ) from tests.common import MockConfigEntry @@ -223,7 +222,10 @@ async def test_user_host_already_configured( ) -> None: """Test host is already configured during user setup.""" entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, options={CONF_VOLUME_STEP: VOLUME_STEP} + domain=DOMAIN, + data=MOCK_SPEAKER_CONFIG, + options={CONF_VOLUME_STEP: VOLUME_STEP}, + unique_id=UNIQUE_ID, ) entry.add_to_hass(hass) fail_entry = MOCK_SPEAKER_CONFIG.copy() @@ -234,61 +236,15 @@ async def test_user_host_already_configured( ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_HOST: "host_exists"} + assert result["errors"] == {CONF_HOST: "existing_config_entry_found"} -async def test_user_host_already_configured_no_port( +async def test_user_serial_number_already_exists( hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_bypass_setup: pytest.fixture, ) -> None: - """Test host is already configured during user setup when existing entry has no port.""" - # Mock entry without port so we can test that the same entry WITH a port will fail - no_port_entry = MOCK_SPEAKER_CONFIG.copy() - no_port_entry[CONF_HOST] = no_port_entry[CONF_HOST].split(":")[0] - entry = MockConfigEntry( - domain=DOMAIN, data=no_port_entry, options={CONF_VOLUME_STEP: VOLUME_STEP} - ) - entry.add_to_hass(hass) - fail_entry = MOCK_SPEAKER_CONFIG.copy() - fail_entry[CONF_NAME] = "newtestname" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=fail_entry - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_HOST: "host_exists"} - - -async def test_user_name_already_configured( - hass: HomeAssistantType, - vizio_connect: pytest.fixture, - vizio_bypass_setup: pytest.fixture, -) -> None: - """Test name is already configured during user setup.""" - entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, options={CONF_VOLUME_STEP: VOLUME_STEP} - ) - entry.add_to_hass(hass) - - fail_entry = MOCK_SPEAKER_CONFIG.copy() - fail_entry[CONF_HOST] = "0.0.0.0" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=fail_entry - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_NAME: "name_exists"} - - -async def test_user_esn_already_exists( - hass: HomeAssistantType, - vizio_connect: pytest.fixture, - vizio_bypass_setup: pytest.fixture, -) -> None: - """Test ESN is already configured with different host and name during user setup.""" + """Test serial_number is already configured with different host and name during user setup.""" # Set up new entry MockConfigEntry( domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID @@ -303,14 +259,26 @@ async def test_user_esn_already_exists( DOMAIN, context={"source": SOURCE_USER}, data=fail_entry ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "existing_config_entry_found"} async def test_user_error_on_could_not_connect( + hass: HomeAssistantType, vizio_no_unique_id: pytest.fixture +) -> None: + """Test with could_not_connect during user setup due to no connectivity.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "cannot_connect"} + + +async def test_user_error_on_could_not_connect_invalid_token( hass: HomeAssistantType, vizio_cant_connect: pytest.fixture ) -> None: - """Test with could_not_connect during user_setup.""" + """Test with could_not_connect during user setup due to invalid token.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG ) @@ -683,6 +651,7 @@ async def test_import_error( domain=DOMAIN, data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), options={CONF_VOLUME_STEP: VOLUME_STEP}, + unique_id=UNIQUE_ID, ) entry.add_to_hass(hass) fail_entry = MOCK_SPEAKER_CONFIG.copy() @@ -763,10 +732,14 @@ async def test_zeroconf_flow_already_configured( hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_bypass_setup: pytest.fixture, + vizio_guess_device_type: pytest.fixture, ) -> None: """Test entity is already configured during zeroconf setup.""" entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, options={CONF_VOLUME_STEP: VOLUME_STEP} + domain=DOMAIN, + data=MOCK_SPEAKER_CONFIG, + options={CONF_VOLUME_STEP: VOLUME_STEP}, + unique_id=UNIQUE_ID, ) entry.add_to_hass(hass) @@ -778,7 +751,7 @@ async def test_zeroconf_flow_already_configured( # Flow should abort because device is already setup assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured_device" + assert result["reason"] == "already_configured" async def test_zeroconf_dupe_fail( @@ -842,7 +815,7 @@ async def test_zeroconf_abort_when_ignored( data=MOCK_SPEAKER_CONFIG, options={CONF_VOLUME_STEP: VOLUME_STEP}, source=SOURCE_IGNORE, - unique_id=ZEROCONF_HOST, + unique_id=UNIQUE_ID, ) entry.add_to_hass(hass) @@ -860,12 +833,16 @@ async def test_zeroconf_flow_already_configured_hostname( vizio_connect: pytest.fixture, vizio_bypass_setup: pytest.fixture, vizio_hostname_check: pytest.fixture, + vizio_guess_device_type: pytest.fixture, ) -> None: """Test entity is already configured during zeroconf setup when existing entry uses hostname.""" config = MOCK_SPEAKER_CONFIG.copy() config[CONF_HOST] = "hostname" entry = MockConfigEntry( - domain=DOMAIN, data=config, options={CONF_VOLUME_STEP: VOLUME_STEP} + domain=DOMAIN, + data=config, + options={CONF_VOLUME_STEP: VOLUME_STEP}, + unique_id=UNIQUE_ID, ) entry.add_to_hass(hass) @@ -877,7 +854,7 @@ async def test_zeroconf_flow_already_configured_hostname( # Flow should abort because device is already setup assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured_device" + assert result["reason"] == "already_configured" async def test_import_flow_already_configured_hostname(