diff --git a/homeassistant/components/denon_rs232/media_player.py b/homeassistant/components/denon_rs232/media_player.py index 4a7c5acaf631b..062fe8ff91850 100644 --- a/homeassistant/components/denon_rs232/media_player.py +++ b/homeassistant/components/denon_rs232/media_player.py @@ -1,6 +1,7 @@ """Media player platform for the Denon RS-232 integration.""" -from typing import Literal, cast, override +import re +from typing import Any, Literal, cast, override from denon_rs232 import ( MIN_VOLUME_DB, @@ -13,13 +14,17 @@ ) from homeassistant.components.media_player import ( + BrowseError, + BrowseMedia, + MediaClass, MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -71,6 +76,25 @@ InputSource.DAB: "dab", } +TUNER_PRESETS_ROOT = "presets" +TUNER_FREQUENCY_MIN = 8750 +TUNER_FREQUENCY_MAX = 10800 +TUNER_FREQUENCY_LENGTH = 6 +#: Reported frequencies at or above this value are AM, which is not supported. +TUNER_FREQUENCY_FM_MAX = 50000 + + +def _tuner_frequency_to_mhz(frequency: str | None) -> str | None: + """Convert a reported tuner frequency to MHz, or None if it is not FM.""" + if frequency is None or not frequency.isdigit(): + return None + + value = int(frequency) + if value >= TUNER_FREQUENCY_FM_MAX: + return None + + return f"{value / 100:.2f}" + async def async_setup_entry( hass: HomeAssistant, @@ -138,7 +162,11 @@ def __init__( if zone == "main": self._attr_name = None - self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + self._attr_supported_features |= ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA + ) else: self._attr_name = "Zone 2" if zone == "zone_2" else "Zone 3" @@ -172,6 +200,13 @@ def _async_update_from_player(self) -> None: source = self._player.input_source self._attr_source = INPUT_SOURCE_DENON_TO_HA.get(source) if source else None + if source is InputSource.TUNER: + self._attr_media_channel = _tuner_frequency_to_mhz( + self._receiver.state.main_zone.tuner_frequency + ) + else: + self._attr_media_channel = None + volume_min = self._player.volume_min volume_max = self._player.volume_max if volume_min is not None: @@ -239,3 +274,62 @@ async def async_select_source(self, source: str) -> None: raise HomeAssistantError("Invalid source") await self._player.select_input_source(input_source) + + @override + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Tune to a tuner preset or an FM frequency.""" + if media_type != MediaType.CHANNEL: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_media_type", + translation_placeholders={"media_type": str(media_type)}, + ) + + player = cast(MainPlayer, self._player) + if re.fullmatch(r"[A-G][1-8]", media_id): + await player.set_tuner_preset(media_id) + elif (match := re.fullmatch(r"0*([0-9]{1,5})", media_id)) and ( + TUNER_FREQUENCY_MIN <= (frequency := int(match[1])) <= TUNER_FREQUENCY_MAX + ): + await player.set_tuner_frequency(f"{frequency:0{TUNER_FREQUENCY_LENGTH}d}") + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_tuner_channel", + translation_placeholders={"media_id": media_id}, + ) + + @override + async def async_browse_media( + self, + media_content_type: MediaType | str | None = None, + media_content_id: str | None = None, + ) -> BrowseMedia: + """List the tuner presets as playable channels.""" + if media_content_id not in (None, TUNER_PRESETS_ROOT): + raise BrowseError(f"Media not found: {media_content_id}") + + return BrowseMedia( + title="Tuner presets", + media_class=MediaClass.DIRECTORY, + media_content_id=TUNER_PRESETS_ROOT, + media_content_type=MediaType.CHANNELS, + can_play=False, + can_expand=True, + children_media_class=MediaClass.CHANNEL, + children=[ + BrowseMedia( + title=preset, + media_class=MediaClass.CHANNEL, + media_content_id=preset, + media_content_type=MediaType.CHANNEL, + can_play=True, + can_expand=False, + ) + for preset in ( + f"{bank}{number}" for bank in "ABCDEFG" for number in range(1, 9) + ) + ], + ) diff --git a/homeassistant/components/denon_rs232/strings.json b/homeassistant/components/denon_rs232/strings.json index 2ed91a0fb290a..af70480b75eb1 100644 --- a/homeassistant/components/denon_rs232/strings.json +++ b/homeassistant/components/denon_rs232/strings.json @@ -74,6 +74,14 @@ } } }, + "exceptions": { + "invalid_tuner_channel": { + "message": "{media_id} is not a valid tuner preset (A1-G8) or FM frequency in hundredths of MHz (8750-10800; for example, 9930 for 99.30 MHz)." + }, + "unsupported_media_type": { + "message": "Cannot play media of type {media_type}. Only tuner channels are supported." + } + }, "selector": { "model": { "options": { diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 7eb5b9744daf0..d09e7d7f64819 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "mqtt": ["esphome/discover/#"], "quality_scale": "platinum", "requirements": [ - "aioesphomeapi==45.6.0", + "aioesphomeapi==45.6.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.9.7" ], diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index c3d17460e31d9..3afc4d252e7e2 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -4,7 +4,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, Platform +from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -108,6 +108,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, unique_id=controller.device_uid, title=new_title, + data={CONF_HOST: controller.device_ip}, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 8525473c34df8..7c526a0d3ae40 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -242,8 +242,7 @@ async def async_step_integration_discovery( await self.async_set_unique_id(uid) self._abort_if_unique_id_configured() - # Discovery host is for confirm-step context only; runtime discovery owns - # current device IP state and keeps it up to date independently of entry data. + # Persist through confirm into entry data as CONF_HOST. self._discovered_controller_ip = host return await self.async_step_confirm() @@ -357,7 +356,7 @@ async def _async_create_controller_entry( self._abort_if_unique_id_configured() return self.async_create_entry( title=self._entry_title(controller.device_uid), - data={}, + data={CONF_HOST: controller.device_ip}, ) @callback diff --git a/requirements_all.txt b/requirements_all.txt index 46f5c39ced23d..4fbebcbb1ead3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -260,7 +260,7 @@ aioelectricitymaps==1.1.1 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==45.6.0 +aioesphomeapi==45.6.1 # homeassistant.components.matrix # homeassistant.components.slack diff --git a/tests/components/denon_rs232/conftest.py b/tests/components/denon_rs232/conftest.py index 02e5aef2b642a..5e7a5773cec42 100644 --- a/tests/components/denon_rs232/conftest.py +++ b/tests/components/denon_rs232/conftest.py @@ -99,6 +99,7 @@ def _default_state() -> MockState: digital_input=DigitalInputMode.AUTO, tuner_band=TunerBand.FM, tuner_mode=TunerMode.AUTO, + tuner_frequency="009930", ), zone_2=ZoneState( power=True, diff --git a/tests/components/denon_rs232/snapshots/test_media_player.ambr b/tests/components/denon_rs232/snapshots/test_media_player.ambr index 1fbb93c99cfd2..d07b3235148c5 100644 --- a/tests/components/denon_rs232/snapshots/test_media_player.ambr +++ b/tests/components/denon_rs232/snapshots/test_media_player.ambr @@ -44,7 +44,7 @@ 'platform': 'denon_rs232', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'receiver', 'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_main', 'unit_of_measurement': None, @@ -70,7 +70,7 @@ 'vcr_2', 'vdp', ]), - : , + : , : 0.5555555555555556, }), 'context': , @@ -137,6 +137,7 @@ 'attributes': ReadOnlyDict({ : 'receiver', : 'AVR-3805 Zone 2', + : '99.30', : 'tuner', : list([ 'cd', diff --git a/tests/components/denon_rs232/test_media_player.py b/tests/components/denon_rs232/test_media_player.py index dc138272c15eb..20280c28ade6d 100644 --- a/tests/components/denon_rs232/test_media_player.py +++ b/tests/components/denon_rs232/test_media_player.py @@ -8,14 +8,22 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.denon_rs232.media_player import INPUT_SOURCE_DENON_TO_HA +from homeassistant.components.denon_rs232.media_player import ( + INPUT_SOURCE_DENON_TO_HA, + TUNER_PRESETS_ROOT, +) from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_CHANNEL, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, + MediaType, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -30,13 +38,14 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.util.json import load_json from .conftest import MockReceiver, MockState, _default_state from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import WebSocketGenerator type ZoneName = Literal["main", "zone_2", "zone_3"] @@ -46,6 +55,9 @@ STRINGS_PATH = Path("homeassistant/components/denon_rs232/strings.json") +# The 56 tuner presets the integration exposes, A1 through G8. +TUNER_PRESETS = [f"{bank}{number}" for bank in "ABCDEFG" for number in range(1, 9)] + @pytest.fixture(autouse=True) async def auto_init_components(init_components) -> None: @@ -310,6 +322,303 @@ async def test_main_invalid_source_raises( ) +@pytest.mark.parametrize( + ("media_id", "expected_command"), + [ + pytest.param("A1", ("TP", "A1"), id="first_preset"), + pytest.param("G8", ("TP", "G8"), id="last_preset"), + pytest.param("C5", ("TP", "C5"), id="preset"), + pytest.param("8750", ("TF", "008750"), id="lowest_frequency"), + pytest.param("10800", ("TF", "010800"), id="highest_frequency"), + pytest.param("9930", ("TF", "009930"), id="frequency"), + pytest.param("009930", ("TF", "009930"), id="padded_frequency"), + pytest.param("00009930", ("TF", "009930"), id="overpadded_frequency"), + pytest.param("0000008750", ("TF", "008750"), id="overpadded_lowest_frequency"), + pytest.param( + "0" * 5000 + "8750", ("TF", "008750"), id="leading_zeros_beyond_int_limit" + ), + ], +) +async def test_main_tuner_play_media( + hass: HomeAssistant, + mock_receiver: MockReceiver, + media_id: str, + expected_command: tuple[str, str], +) -> None: + """Test playing media selects a tuner preset or frequency. + + The default main input source is CD, so this also covers tuning while the + main zone plays another source. + """ + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: media_id, + }, + blocking=True, + ) + assert mock_receiver._send_command.await_args == call(*expected_command) + + +@pytest.mark.parametrize( + ("media_type", "media_id", "translation_key"), + [ + pytest.param( + MediaType.MUSIC, "A1", "unsupported_media_type", id="media_type_not_channel" + ), + pytest.param( + MediaType.CHANNEL, "A", "invalid_tuner_channel", id="media_id_too_short" + ), + pytest.param( + MediaType.CHANNEL, "H1", "invalid_tuner_channel", id="preset_bank_above" + ), + pytest.param( + MediaType.CHANNEL, "A0", "invalid_tuner_channel", id="preset_number_zero" + ), + pytest.param( + MediaType.CHANNEL, "A9", "invalid_tuner_channel", id="preset_number_above" + ), + pytest.param( + MediaType.CHANNEL, "a1", "invalid_tuner_channel", id="preset_lowercase" + ), + pytest.param( + MediaType.CHANNEL, "A1B", "invalid_tuner_channel", id="preset_too_long" + ), + pytest.param( + MediaType.CHANNEL, + "8749", + "invalid_tuner_channel", + id="frequency_below_range", + ), + pytest.param( + MediaType.CHANNEL, + "10801", + "invalid_tuner_channel", + id="frequency_above_range", + ), + pytest.param( + MediaType.CHANNEL, "1000", "invalid_tuner_channel", id="am_frequency" + ), + pytest.param( + MediaType.CHANNEL, + "99.30", + "invalid_tuner_channel", + id="frequency_not_an_integer", + ), + pytest.param( + MediaType.CHANNEL, "not a channel", "invalid_tuner_channel", id="unparsable" + ), + pytest.param( + MediaType.CHANNEL, + "9" * 5000, + "invalid_tuner_channel", + id="frequency_exceeds_int_conversion_limit", + ), + pytest.param( + MediaType.CHANNEL, + "0" * 5000, + "invalid_tuner_channel", + id="zeros_exceed_int_conversion_limit", + ), + ], +) +async def test_main_tuner_play_media_invalid_input_raises( + hass: HomeAssistant, + mock_receiver: MockReceiver, + media_type: MediaType, + media_id: str, + translation_key: str, +) -> None: + """Test playing invalid media raises and sends no tuner command.""" + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: media_type, + ATTR_MEDIA_CONTENT_ID: media_id, + }, + blocking=True, + ) + + assert err.value.translation_key == translation_key + assert mock_receiver._send_command.await_count == 0 + + +@pytest.mark.parametrize("entity_id", [ZONE_2_ENTITY_ID, ZONE_3_ENTITY_ID]) +async def test_zones_do_not_support_play_media( + hass: HomeAssistant, entity_id: str +) -> None: + """Test playing media is rejected for zones, which have no tuner control.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: "A1", + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("tuner_frequency", "expected_channel"), + [ + pytest.param("009930", "99.30", id="fm_frequency"), + pytest.param("008750", "87.50", id="lowest_fm_frequency"), + pytest.param("010800", "108.00", id="highest_fm_frequency"), + pytest.param("010000", "100.00", id="whole_mhz_frequency"), + pytest.param(None, None, id="frequency_unknown"), + pytest.param("050000", None, id="am_threshold"), + pytest.param("099990", None, id="am_frequency"), + pytest.param("00AM10", None, id="not_a_number"), + ], +) +async def test_tuner_frequency_media_channel( + hass: HomeAssistant, + mock_receiver: MockReceiver, + tuner_frequency: str | None, + expected_channel: str | None, +) -> None: + """Test the tuner frequency is reported in MHz as the media channel.""" + state = _default_state() + state.main_zone.input_source = InputSource.TUNER + state.main_zone.tuner_frequency = tuner_frequency + mock_receiver.mock_state(state) + await hass.async_block_till_done() + + entity_state = hass.states.get(MAIN_ENTITY_ID) + assert entity_state.attributes.get(ATTR_MEDIA_CHANNEL) == expected_channel + + +async def test_tuner_frequency_not_reported_for_other_sources( + hass: HomeAssistant, mock_receiver: MockReceiver +) -> None: + """Test the media channel is cleared when the zone leaves the tuner source.""" + state = _default_state() + state.main_zone.input_source = InputSource.TUNER + mock_receiver.mock_state(state) + await hass.async_block_till_done() + + assert hass.states.get(MAIN_ENTITY_ID).attributes[ATTR_MEDIA_CHANNEL] == "99.30" + + state = _default_state() + state.main_zone.input_source = InputSource.CD + mock_receiver.mock_state(state) + await hass.async_block_till_done() + + entity_state = hass.states.get(MAIN_ENTITY_ID) + assert ATTR_MEDIA_CHANNEL not in entity_state.attributes + + +async def test_tuner_frequency_shared_by_zones( + hass: HomeAssistant, mock_receiver: MockReceiver +) -> None: + """Test a zone on the tuner source reports the shared main zone frequency.""" + state = _default_state() + state.main_zone.tuner_frequency = "010110" + mock_receiver.mock_state(state) + await hass.async_block_till_done() + + entity_state = hass.states.get(ZONE_2_ENTITY_ID) + assert entity_state.attributes[ATTR_MEDIA_CHANNEL] == "101.10" + + +async def test_browse_media_lists_tuner_presets( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test browsing returns every tuner preset as a playable channel.""" + client = await hass_ws_client() + await client.send_json_auto_id( + { + "type": "media_player/browse_media", + "entity_id": MAIN_ENTITY_ID, + } + ) + response = await client.receive_json() + + assert response["success"] + result = response["result"] + assert result["media_content_id"] == TUNER_PRESETS_ROOT + assert not result["can_play"] + assert result["can_expand"] + + children = result["children"] + preset_ids = [child["media_content_id"] for child in children] + assert len(preset_ids) == 56 + assert (preset_ids[0], preset_ids[-1]) == ("A1", "G8") + assert preset_ids == TUNER_PRESETS + assert children[0] == { + "title": "A1", + "media_class": "channel", + "media_content_type": "channel", + "media_content_id": "A1", + "can_play": True, + "can_expand": False, + "can_search": False, + "thumbnail": None, + "children_media_class": None, + } + + +async def test_browse_media_invalid_content_id( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test browsing an unknown content id fails.""" + client = await hass_ws_client() + await client.send_json_auto_id( + { + "type": "media_player/browse_media", + "entity_id": MAIN_ENTITY_ID, + "media_content_id": "unknown", + } + ) + response = await client.receive_json() + + assert not response["success"] + + +@pytest.mark.parametrize("entity_id", [ZONE_2_ENTITY_ID, ZONE_3_ENTITY_ID]) +async def test_browse_media_not_supported_for_zones( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entity_id: str +) -> None: + """Test only the main zone controls the shared tuner presets.""" + client = await hass_ws_client() + await client.send_json_auto_id( + { + "type": "media_player/browse_media", + "entity_id": entity_id, + } + ) + response = await client.receive_json() + + assert not response["success"] + + +async def test_browsed_preset_tunes_when_played( + hass: HomeAssistant, mock_receiver: MockReceiver +) -> None: + """Test every browsed preset is a valid play_media input.""" + for preset in TUNER_PRESETS: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: MAIN_ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: MediaType.CHANNEL, + ATTR_MEDIA_CONTENT_ID: preset, + }, + blocking=True, + ) + assert mock_receiver._send_command.await_args == call("TP", preset) + + def test_input_source_translation_keys_cover_all_enum_members() -> None: """Test all input sources have a declared translation key.""" assert set(INPUT_SOURCE_DENON_TO_HA) == set(InputSource) diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 2d5045edcc994..9869b0e90276d 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -61,7 +61,7 @@ async def test_user_discovery_success( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000001" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.55"} assert result["result"].unique_id == "000000001" @@ -82,7 +82,7 @@ async def test_user_discovery_default_selects_first_and_queues_other( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000001" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.1"} assert result["result"].unique_id == "000000001" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -120,7 +120,7 @@ async def test_broadcast_skips_already_configured_controller( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000002" @@ -143,7 +143,7 @@ async def test_user_discovery_skips_yaml_excluded_controllers( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000002" @@ -172,7 +172,7 @@ async def test_broadcast_multiple_unconfigured_shows_choice( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000001" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000001" entries = hass.config_entries.async_entries(DOMAIN) @@ -251,6 +251,7 @@ async def test_select_controller_creates_selected_uid_and_queues_others( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" + assert result["data"] == {CONF_HOST: "192.0.2.1"} assert result["result"].unique_id == "000000002" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -331,7 +332,7 @@ async def test_reuses_existing_discovery_service( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000002" mock_pizone_discovery.assert_not_called() @@ -427,7 +428,7 @@ async def test_homekit_confirm_uses_discovered_host( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000001" - assert result["data"] == {} + assert result["data"] == {CONF_HOST: "192.0.2.3"} assert result["result"].unique_id == "000000001" @@ -882,6 +883,7 @@ async def test_integration_discovery_confirm_creates_entry( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" + assert result["data"] == {CONF_HOST: "192.0.2.2"} assert result["result"].unique_id == "000000002" @@ -1230,12 +1232,12 @@ def test_async_fan_out_skips_uids_already_in_progress() -> None: async def test_async_migrate_entry_clears_legacy_data( hass: HomeAssistant, ) -> None: - """v1→v2 migration clears legacy entry data; UID and title binding is deferred. + """v1→v2 migration clears legacy entry data; UID/host binding is deferred to setup. ConfigEntryNotReady retry semantics only work inside async_setup_entry — raising from async_migrate_entry permanently lands the entry in MIGRATION_ERROR with no retry path. All network-dependent work is therefore intentionally deferred to - async_setup_entry. + async_setup_entry, which also persists CONF_HOST when the UID is resolved. """ entry = MockConfigEntry( domain=DOMAIN, @@ -1258,7 +1260,7 @@ async def test_async_migrate_entry_clears_legacy_data( await hass.async_block_till_done() assert entry.version == 2 - assert entry.data == {} + assert entry.data == {CONF_HOST: "192.0.2.1"} assert entry.unique_id == "000000001" assert entry.title == "iZone 000000001" @@ -1377,6 +1379,7 @@ async def test_setup_entry_resolves_legacy_uid_and_updates_title( assert entry.unique_id == "000000001" assert entry.title == expected_title + assert entry.data == {CONF_HOST: "192.0.2.2"} @pytest.mark.parametrize( @@ -1502,7 +1505,7 @@ async def test_setup_entry_picks_eligible_controller_after_filtering_for_legacy_ await hass.async_block_till_done() assert entry.unique_id == "000000002" - assert entry.data == {} + assert entry.data == {CONF_HOST: "192.0.2.2"} @pytest.mark.parametrize(