Add WiiM media player integration#148948
Conversation
|
Please take a look at the requested changes, and use the Ready for review button when you are done, thanks 👍 |
There was a problem hiding this comment.
It seems you haven't yet signed a CLA. Please do so here.
Once you do that we will be able to review and accept this pull request.
Thanks!
|
Address remaining WiiM review follow-ups
|
|
||
| @callback | ||
| def _handle_sdk_av_transport_event( | ||
| self, service: UpnpService, state_variables: list[UpnpStateVariable] |
| async def _async_probe_wiim_host(hass: HomeAssistant, host: str) -> WiimProbeResult: | ||
| """Probe the given host and return WiiM device information.""" | ||
| session = async_get_clientsession(hass) | ||
| location = f"http://{host}:{UPNP_PORT}/description.xml" | ||
| LOGGER.debug("Validating UPnP device at location: %s", location) | ||
| try: | ||
| probe_result = await async_probe_wiim_device( | ||
| location, | ||
| session, | ||
| host=host, | ||
| ) | ||
| except TimeoutError as err: | ||
| raise CannotConnect from err | ||
|
|
||
| if probe_result is None: | ||
| raise CannotConnect | ||
| return probe_result |
| "homeassistant.components.media_source.async_browse_media", | ||
| AsyncMock(return_value=mock_browse_item), | ||
| ) as mock_browse_media: | ||
| browse_result = await entity.async_browse_media(MEDIA_CONTENT_ID_ROOT) |
| | MediaPlayerEntityFeature.BROWSE_MEDIA | ||
| | MediaPlayerEntityFeature.PLAY_MEDIA | ||
| | MediaPlayerEntityFeature.SELECT_SOURCE | ||
| | MediaPlayerEntityFeature.GROUPING |
| class WiimConfigFlow(ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for WiiM.""" | ||
|
|
||
| _discovered_info: WiimProbeResult | None = None |
There was a problem hiding this comment.
Don't assign it here, just make it a type annotation:
| _discovered_info: WiimProbeResult | None = None | |
| _discovered_info: WiimProbeResult |
| ) -> ConfigFlowResult: | ||
| """Handle user confirmation of discovered device.""" | ||
| discovered_info = self._discovered_info | ||
| if user_input is not None and discovered_info is not None: |
There was a problem hiding this comment.
discovered_info can't be None
tests/components/wiim/conftest.py
Outdated
| @pytest.fixture(autouse=True) | ||
| def mock_sdk_logger(): | ||
| """Mocks the LOGGER to prevent actual logging and allow assertion of calls.""" | ||
| with ( | ||
| patch("homeassistant.components.wiim.const.LOGGER.warning") as mock_warning, | ||
| patch("homeassistant.components.wiim.const.LOGGER.debug") as mock_debug, | ||
| patch("homeassistant.components.wiim.const.LOGGER.info") as mock_info, | ||
| patch("homeassistant.components.wiim.const.LOGGER.error") as mock_error, | ||
| ): | ||
| yield mock_warning, mock_debug, mock_info, mock_error |
There was a problem hiding this comment.
We don't have to patch the logger. It's not bad to log during tests. If there's something we should load from the logger, we can use caplog to capture logs
tests/components/wiim/conftest.py
Outdated
| @pytest.fixture | ||
| async def mock_hass(hass: HomeAssistant) -> HomeAssistant: | ||
| """Return a real HomeAssistant instance for integration tests.""" | ||
| return hass |
tests/components/wiim/conftest.py
Outdated
| "host": "192.168.1.100", | ||
| "udn": "uuid:test-udn-1234", | ||
| "name": "Test WiiM Device", | ||
| }, |
There was a problem hiding this comment.
Use constants for the data keys and the domain
tests/components/wiim/conftest.py
Outdated
| "udn": "uuid:test-udn-1234", | ||
| "name": "Test WiiM Device", | ||
| }, | ||
| title="Test WiiM Device", | ||
| source="user", |
There was a problem hiding this comment.
udn and name aren't stored anymore. We also don't need source. However, udn is now set as the unique_id, so let's make sure the mock_config_entry reflects a real world config entry
tests/components/wiim/conftest.py
Outdated
|
|
||
|
|
||
| @pytest.fixture | ||
| def mock_config_entry() -> ConfigEntry: |
There was a problem hiding this comment.
| def mock_config_entry() -> ConfigEntry: | |
| def mock_config_entry() -> MockConfigEntry: |
tests/components/wiim/test_init.py
Outdated
|
|
||
| assert result is True | ||
| mock_controller_cls.assert_called_once_with(mock_session) | ||
| assert hass.data[DATA_WIIM] == WiimData(controller=mock_controller) |
tests/components/wiim/test_init.py
Outdated
| async def test_async_setup_entry_device_init_failure( | ||
| mock_hass: HomeAssistant, | ||
| mock_config_entry: ConfigEntry, | ||
| ) -> None: | ||
| """Test async_setup_entry when device initialization fails.""" | ||
| mock_config_entry.add_to_hass(mock_hass) | ||
| mock_session = AsyncMock() | ||
|
|
||
| with ( | ||
| patch( | ||
| "homeassistant.components.wiim.async_create_wiim_device", | ||
| side_effect=WiimDeviceException("Failed to initialize WiiM device"), | ||
| ), | ||
| patch( | ||
| "homeassistant.components.wiim.async_get_clientsession", | ||
| return_value=mock_session, | ||
| ), | ||
| patch("homeassistant.components.wiim.WiimController") as mock_controller_class, | ||
| patch( | ||
| "homeassistant.components.wiim.get_url", | ||
| return_value="http://192.168.1.10:8123", | ||
| ), | ||
| ): | ||
| mock_controller_instance = AsyncMock() | ||
| mock_controller_class.return_value = mock_controller_instance | ||
|
|
||
| with pytest.raises(ConfigEntryNotReady): | ||
| await async_setup_entry(mock_hass, mock_config_entry) |
There was a problem hiding this comment.
instead have HA set it up, and assert the entry.state
tests/components/wiim/test_init.py
Outdated
| async def test_async_setup_entry_device_init_failure( | ||
| mock_hass: HomeAssistant, | ||
| mock_config_entry: ConfigEntry, | ||
| ) -> None: | ||
| """Test async_setup_entry when device initialization fails.""" | ||
| mock_config_entry.add_to_hass(mock_hass) | ||
| mock_session = AsyncMock() | ||
|
|
||
| with ( | ||
| patch( | ||
| "homeassistant.components.wiim.async_create_wiim_device", | ||
| side_effect=WiimDeviceException("Failed to initialize WiiM device"), | ||
| ), | ||
| patch( | ||
| "homeassistant.components.wiim.async_get_clientsession", | ||
| return_value=mock_session, | ||
| ), | ||
| patch("homeassistant.components.wiim.WiimController") as mock_controller_class, | ||
| patch( | ||
| "homeassistant.components.wiim.get_url", | ||
| return_value="http://192.168.1.10:8123", | ||
| ), | ||
| ): | ||
| mock_controller_instance = AsyncMock() | ||
| mock_controller_class.return_value = mock_controller_instance | ||
|
|
||
| with pytest.raises(ConfigEntryNotReady): | ||
| await async_setup_entry(mock_hass, mock_config_entry) | ||
|
|
||
|
|
||
| async def test_async_setup_entry_request_exception( | ||
| mock_hass: HomeAssistant, | ||
| mock_config_entry: ConfigEntry, | ||
| ) -> None: | ||
| """Test async_setup_entry when HTTP API request fails.""" | ||
| mock_config_entry.add_to_hass(mock_hass) | ||
| mock_session = AsyncMock() | ||
|
|
||
| with ( | ||
| patch( | ||
| "homeassistant.components.wiim.async_create_wiim_device", | ||
| side_effect=WiimRequestException("HTTP failure"), | ||
| ), | ||
| patch( | ||
| "homeassistant.components.wiim.async_get_clientsession", | ||
| return_value=mock_session, | ||
| ), | ||
| patch("homeassistant.components.wiim.WiimController") as mock_controller_class, | ||
| patch( | ||
| "homeassistant.components.wiim.get_url", | ||
| return_value="http://192.168.1.10:8123", | ||
| ), | ||
| ): | ||
| mock_controller_instance = AsyncMock() | ||
| mock_controller_class.return_value = mock_controller_instance | ||
|
|
||
| with pytest.raises(ConfigEntryNotReady): | ||
| await async_setup_entry(mock_hass, mock_config_entry) | ||
|
|
||
|
|
||
| async def test_async_setup_entry_no_url_available( | ||
| mock_hass: HomeAssistant, | ||
| mock_config_entry: ConfigEntry, | ||
| ) -> None: | ||
| """Test async_setup_entry raises when Home Assistant URL is unavailable.""" | ||
| mock_config_entry.add_to_hass(mock_hass) | ||
| mock_session = AsyncMock() | ||
|
|
||
| with ( | ||
| patch( | ||
| "homeassistant.components.wiim.async_get_clientsession", | ||
| return_value=mock_session, | ||
| ), | ||
| patch("homeassistant.components.wiim.WiimController") as mock_controller_class, | ||
| patch( | ||
| "homeassistant.components.wiim.get_url", | ||
| side_effect=NoURLAvailableError, | ||
| ), | ||
| ): | ||
| mock_controller_instance = AsyncMock() | ||
| mock_controller_class.return_value = mock_controller_instance | ||
|
|
||
| with pytest.raises(ConfigEntryNotReady): | ||
| await async_setup_entry(mock_hass, mock_config_entry) |
There was a problem hiding this comment.
These 3 tests can be parametrized
tests/components/wiim/test_init.py
Outdated
| mock_session = AsyncMock() | ||
| hass.config_entries.async_forward_entry_setups = AsyncMock(return_value=True) | ||
|
|
||
| unload_callbacks: list[object] = [] | ||
|
|
||
| def _capture_callback(cb): | ||
| unload_callbacks.append(cb) | ||
|
|
||
| mock_config_entry.async_on_unload = MagicMock(side_effect=_capture_callback) | ||
|
|
||
| with ( | ||
| patch("homeassistant.components.wiim.WiimController") as mock_controller_cls, | ||
| patch( | ||
| "homeassistant.components.wiim.async_create_wiim_device" | ||
| ) as mock_create_wiim_device, | ||
| patch( | ||
| "homeassistant.components.wiim.async_get_clientsession", | ||
| return_value=mock_session, | ||
| ), | ||
| patch( | ||
| "homeassistant.components.wiim.get_url", | ||
| return_value="http://192.168.1.10:8123", | ||
| ), | ||
| ): | ||
| mock_controller = MagicMock() | ||
| mock_controller.add_device = AsyncMock() | ||
| mock_controller.remove_device = AsyncMock() | ||
| mock_controller_cls.return_value = mock_controller | ||
|
|
||
| mock_device = MagicMock() | ||
| mock_device.udn = "test-udn" | ||
| mock_device.name = "Test Device" | ||
| mock_device.disconnect = AsyncMock() | ||
| mock_create_wiim_device.return_value = mock_device | ||
|
|
||
| result = await async_setup_entry(hass, mock_config_entry) | ||
|
|
||
| assert result is True | ||
| assert len(unload_callbacks) == 2 | ||
|
|
||
| await unload_callbacks[1]() |
There was a problem hiding this comment.
We shouldn't patch internals, ask HA to setup the integration and to unload it. We can then assert on the mocks if the right methods have been called
tests/components/wiim/test_init.py
Outdated
| async def test_async_unload_entry_success( | ||
| mock_hass: HomeAssistant, | ||
| mock_config_entry: ConfigEntry, | ||
| mock_wiim_device: WiimDevice, | ||
| mock_wiim_controller: WiimController, | ||
| ) -> None: | ||
| """Test successful unloading of a config entry.""" | ||
| mock_config_entry.runtime_data = mock_wiim_device | ||
|
|
||
| mock_hass.data[DATA_WIIM] = WiimData( | ||
| controller=mock_wiim_controller, | ||
| entity_id_to_udn_map={"media_player.test": "uuid:123"}, | ||
| ) | ||
|
|
||
| mock_hass.config_entries.async_unload_platforms = AsyncMock(return_value=True) | ||
|
|
||
| with patch.object( | ||
| mock_hass.config_entries, "async_loaded_entries", return_value=[] | ||
| ) as mock_loaded_entries: | ||
| result = await async_unload_entry(mock_hass, mock_config_entry) | ||
|
|
||
| assert result is True | ||
|
|
||
| mock_loaded_entries.assert_called_once_with(DOMAIN) | ||
|
|
||
| assert DATA_WIIM not in mock_hass.data | ||
|
|
||
| mock_hass.config_entries.async_unload_platforms.assert_awaited_once_with( | ||
| mock_config_entry, PLATFORMS | ||
| ) | ||
|
|
||
|
|
||
| async def test_async_unload_entry_returns_false( | ||
| mock_hass: HomeAssistant, | ||
| mock_config_entry: ConfigEntry, | ||
| ) -> None: | ||
| """Test async_unload_entry returns False when platform unload fails.""" | ||
| mock_hass.config_entries.async_unload_platforms = AsyncMock(return_value=False) | ||
|
|
||
| result = await async_unload_entry(mock_hass, mock_config_entry) | ||
|
|
||
| assert result is False |
| def _set_wiim_data( | ||
| hass: HomeAssistant, | ||
| *, | ||
| controller: MagicMock | None = None, | ||
| entity_id_to_udn_map: dict[str, str] | None = None, | ||
| ) -> None: | ||
| """Populate the typed WiiM domain data for a test Home Assistant instance.""" | ||
| hass.data[DATA_WIIM] = WiimData( | ||
| controller=controller or MagicMock(), | ||
| entity_id_to_udn_map=entity_id_to_udn_map or {}, | ||
| ) |
There was a problem hiding this comment.
We shouldn't touch internals. Instead the controller seems to be a library component, so that would be patched out, meaning we can interact with it and change its behaviour
| mock_config_entry.runtime_data = mock_wiim_device | ||
| _set_wiim_data(mock_hass) | ||
| await async_setup_entry(mock_hass, mock_config_entry, mock_add_entities) |
There was a problem hiding this comment.
entry.runtime_data is also considered an internal. Instead we should have HA setup the integration
| mock_add_entities.assert_called_once() | ||
| entities = mock_add_entities.call_args[0][0] | ||
| assert len(entities) == 1 | ||
| entity = entities[0] | ||
| assert isinstance(entity, WiimMediaPlayerEntity) | ||
| assert entity._device is mock_wiim_device | ||
|
|
||
|
|
||
| async def test_media_player_attributes( | ||
| mock_wiim_media_player_entity: WiimMediaPlayerEntity, mock_wiim_device: WiimDevice | ||
| ) -> None: | ||
| """Test media player entity attributes.""" | ||
| entity = mock_wiim_media_player_entity | ||
| entity._device = mock_wiim_device | ||
| entity._attr_name = mock_wiim_device.name # type: ignore[assignment] | ||
| entity._attr_unique_id = mock_wiim_device.udn | ||
| entity._attr_device_info = { | ||
| "identifiers": {(DOMAIN, mock_wiim_device.udn)}, | ||
| "name": mock_wiim_device.name, | ||
| } | ||
| entity._transport_capabilities = None | ||
| entity._attr_device_class = MediaPlayerDeviceClass.SPEAKER | ||
| entity._attr_state = MediaPlayerState.IDLE | ||
| entity._attr_volume_level = mock_wiim_device.volume / 100 | ||
| entity._attr_is_volume_muted = mock_wiim_device.is_muted | ||
| entity._attr_repeat = RepeatMode.OFF | ||
| entity._attr_source = InputMode.LINE_IN.display_name # type: ignore[attr-defined] | ||
|
|
||
| assert entity.unique_id == mock_wiim_device.udn | ||
| assert entity.name == mock_wiim_device.name | ||
| assert entity.device_info is not None | ||
| assert entity.device_info["identifiers"] == {(DOMAIN, mock_wiim_device.udn)} | ||
| assert entity.device_info["name"] == mock_wiim_device.name | ||
| assert entity.supported_features == SUPPORT_WIIM_BASE | ||
| assert entity.device_class == MediaPlayerDeviceClass.SPEAKER | ||
|
|
||
| assert entity.state == MediaPlayerState.IDLE | ||
| assert entity.volume_level == mock_wiim_device.volume / 100 | ||
| assert entity.is_volume_muted == mock_wiim_device.is_muted | ||
| assert entity.repeat == RepeatMode.OFF | ||
| assert entity.source == InputMode.LINE_IN.display_name # type: ignore[attr-defined] |
There was a problem hiding this comment.
Instead, replace this with a snapshot test and run pytest ./tests/components/wiim --snapshot-update as that will fixate the entity registry entry and the state in a .ambr file which also should be committed
| async def test_media_player_update_ha_state_from_sdk_cache( | ||
| mock_wiim_media_player_entity: WiimMediaPlayerEntity, | ||
| mock_wiim_device: WiimDevice, | ||
| mock_hass: HomeAssistant, | ||
| ) -> None: | ||
| """Test _update_ha_state_from_sdk_cache schedules HA state update.""" | ||
| entity = mock_wiim_media_player_entity | ||
| _set_wiim_data(mock_hass) | ||
|
|
||
| entity.hass = mock_hass | ||
|
|
||
| mock_wiim_device.playing_status = PlayingStatus.PLAYING | ||
| mock_wiim_device.volume = 60 # 0.6 | ||
| mock_wiim_device.current_media = WiimMediaMetadata(title="New Song") | ||
| mock_wiim_device.available = True # type: ignore[misc] | ||
|
|
||
| entity._device = mock_wiim_device | ||
| entity.entity_id = "media_player.test_device" | ||
| entity._attr_group_members = ["media_player.test_device", "media_player.follower1"] | ||
|
|
||
| with ( | ||
| patch.object(entity, "schedule_update_ha_state", new=MagicMock()), | ||
| patch.object(entity, "async_write_ha_state", new=MagicMock()), | ||
| patch.object( | ||
| entity.hass.data[DATA_WIIM].controller, | ||
| "get_group_snapshot", | ||
| new=MagicMock( | ||
| return_value=MagicMock( | ||
| role=WiimGroupRole.LEADER, | ||
| member_udns=(mock_wiim_device.udn,), | ||
| ) | ||
| ), | ||
| ), | ||
| ): | ||
| entity._update_ha_state_from_sdk_cache() | ||
|
|
||
| assert entity.state == MediaPlayerState.PLAYING | ||
| assert entity.volume_level == 0.6 | ||
| assert entity.media_title == "New Song" |
There was a problem hiding this comment.
Instead this should be triggered naturally
| async def test_media_player_update_ha_state_from_sdk_cache_unavailable( | ||
| mock_wiim_media_player_entity: WiimMediaPlayerEntity, | ||
| mock_wiim_device: WiimDevice, | ||
| mock_hass: HomeAssistant, | ||
| ) -> None: | ||
| """Test unavailable devices clear state and write HA state.""" | ||
| entity = mock_wiim_media_player_entity | ||
| _set_wiim_data(mock_hass) | ||
| entity.hass = mock_hass | ||
| entity._device = mock_wiim_device | ||
| entity._attr_media_title = "Old" | ||
| entity._attr_source = "Old Source" | ||
| entity._transport_capabilities = WiimTransportCapabilities(can_next=True) | ||
| mock_wiim_device.available = False # type: ignore[misc] | ||
|
|
||
| with patch.object(entity, "async_write_ha_state", new=MagicMock()) as mock_write: | ||
| entity._update_ha_state_from_sdk_cache() | ||
|
|
||
| assert entity.state is None | ||
| assert entity.media_title is None | ||
| assert entity.source is None | ||
| assert entity._transport_capabilities is None | ||
| mock_write.assert_called_once() |
There was a problem hiding this comment.
idem. If it's unavailable, we should modify the mock to behave like it's unavailable, and then assert the behavior we expect when this is the case
| async def test_media_player_async_added_to_hass_refreshes_supported_features( | ||
| mock_wiim_media_player_entity: WiimMediaPlayerEntity, | ||
| mock_hass: HomeAssistant, | ||
| ) -> None: | ||
| """Test entity setup refreshes supported features before initial state write.""" | ||
| entity = mock_wiim_media_player_entity | ||
| entity.hass = mock_hass | ||
| entity.entity_id = "media_player.test_device" | ||
| _set_wiim_data(mock_hass) | ||
|
|
||
| with ( | ||
| patch( | ||
| "homeassistant.helpers.entity.Entity.async_added_to_hass", | ||
| new=AsyncMock(), | ||
| ), | ||
| patch.object( | ||
| entity, "_from_device_update_supported_features", new_callable=AsyncMock | ||
| ) as mock_refresh_supported_features, | ||
| patch.object( | ||
| entity, "_update_ha_state_from_sdk_cache", new=MagicMock() | ||
| ) as mock_update_state, | ||
| patch.object( | ||
| entity, "async_write_ha_state", new=MagicMock() | ||
| ) as mock_write_state, | ||
| ): | ||
| await entity.async_added_to_hass() | ||
|
|
||
| assert ( | ||
| entity._device.general_event_callback | ||
| == entity._handle_sdk_general_device_update | ||
| ) | ||
| assert ( | ||
| entity._device.av_transport_event_callback | ||
| == entity._handle_sdk_av_transport_event | ||
| ) | ||
| assert ( | ||
| entity._device.rendering_control_event_callback | ||
| == entity._handle_sdk_refresh_event | ||
| ) | ||
| assert entity._device.play_queue_event_callback == entity._handle_sdk_refresh_event | ||
| assert ( | ||
| entity._wiim_data.entity_id_to_udn_map[entity.entity_id] == entity._device.udn | ||
| ) | ||
| mock_refresh_supported_features.assert_awaited_once_with(write_state=False) | ||
| mock_update_state.assert_called_once_with( | ||
| write_state=False, update_supported_features=False | ||
| ) | ||
| mock_write_state.assert_not_called() |
There was a problem hiding this comment.
This would already be tested with the snapshot test
| event = Event( | ||
| "entity_registry_updated", | ||
| { | ||
| "action": "update", | ||
| "old_entity_id": "media_player.old", | ||
| "entity_id": "media_player.new", | ||
| }, | ||
| ) | ||
|
|
||
| with patch.object(Entity, "_async_registry_updated", new=MagicMock()): | ||
| entity._async_registry_updated(event) |
There was a problem hiding this comment.
Instead we should ask the entity registry to change the entity_id
|
I'm still confused why the strategy here is to go with this PR instead of just moving the excellent https://github.com/mjcumming/wiim into core? The latter works for way more devices, is actually stable and ready etc? |
| elif media_type in {MediaType.MUSIC, MEDIA_TYPE_WIIM_LIBRARY}: | ||
| if not media_id.isdigit(): | ||
| raise ServiceValidationError(f"Invalid preset ID: {media_id}") | ||
|
|
||
| preset_number = int(media_id) | ||
| await target_device.play_preset(preset_number) | ||
| self._attr_media_content_id = f"wiim_preset_{preset_number}" | ||
| self._attr_media_content_type = MediaType.PLAYLIST | ||
| self._attr_state = MediaPlayerState.PLAYING | ||
| elif media_type == MediaType.TRACK: | ||
| if not media_id.isdigit(): | ||
| raise ServiceValidationError( | ||
| f"Invalid media_id: {media_id}. Expected a valid track index." |
| await self._device.async_set_loop_mode( | ||
| self._device.build_loop_mode(WiimRepeatMode(repeat), self._attr_shuffle) |
| async def async_set_shuffle(self, shuffle: bool) -> None: | ||
| """Enable/disable shuffle mode.""" | ||
| repeat = self._attr_repeat or WiimRepeatMode.OFF | ||
| await self._device.async_set_loop_mode( | ||
| self._device.build_loop_mode( | ||
| WiimRepeatMode(repeat), | ||
| shuffle, | ||
| ) | ||
| ) | ||
|
|
| @media_player_exception_wrap | ||
| async def async_select_source(self, source: str) -> None: | ||
| """Select input mode.""" | ||
| await self._device.async_set_play_mode(source) |
Fix WiiM grouped command routing and URL playback
|
For visibility, there is already an existing Home Assistant WiiM integration here: https://github.com/mjcumming/wiim It is already in active use, with about 700 installs, and the repo currently has 88 GitHub stars. That integration already covers a fairly broad set of WiiM/LinkPlay-specific behavior, including:
I think it is important that reviewers and maintainers have visibility into the fact that there is already a fairly mature, actively maintained implementation with a real user base, so any decision about a separate core implementation can be made with that context in mind. |
|
@mjcumming we are aware and thanks for your work on it. It is always better to have a built-in integration in Home Assistant over a custom one that lives in HACS. Built-in allows Home Assistant to automatically discover the integration and offer set-up to the user. Although today it will not be as good as the custom one, we hope that over time we can match all the functions. New integrations always start with the bare minimum, to make it easier to review (as you can see from this PR, even minimal functionality is a lot of review work!). We don't allow contributors to contribute work from other people, which is why this integration has been built from scratch. I agree that time could have probably been saved if the efforts were combined. It would be great if we can collaborate on the built-in integration in the future. |
Merge feature/wiim-integration into dev
| self._device.current_track_duration = 0 | ||
| self._attr_media_position_updated_at = None | ||
| self._attr_media_duration = None | ||
| self._attr_media_position = None |
| async def test_state_machine_updates_from_device_callbacks( | ||
| hass: HomeAssistant, | ||
| mock_wiim_device, | ||
| init_wiim_media_player, | ||
| ) -> None: |
| async def test_user_flow_already_configured( | ||
| hass: HomeAssistant, mock_config_entry | ||
| ) -> None: |
| async def test_zeroconf_flow_already_configured( | ||
| hass: HomeAssistant, mock_config_entry | ||
| ) -> None: |
| LOGGER.info( | ||
| "WiiM device %s (UDN: %s) linked to HASS. Name: '%s', HTTP: %s, UPnP Location: %s", | ||
| entry.entry_id, | ||
| wiim_device.udn, | ||
| wiim_device.name, | ||
| host, |
|
I think the goal makes sense on paper: one integration path that works well for LinkPlay‑based gear (including WiiM) is better UX than a maze of partially overlapping options. The tension is what "one integration" should sit on technically. The LinkPlay code that landed in core (migrated from the older user integration) is in a different era than how HA does things today—e.g. async patterns, structure, and ongoing maintenance burden. So "just extend that forever" is not obviously cheaper than "replace the transport/library layer with something actively maintained," if we are honest about long‑term fit. pywiim was not written as a "WiiM‑only" fork of one OEM stack; it is aimed at the broader LinkPlay ecosystem (many brands/devices) with device‑specific features where the API exposes them (WiiM‑only capabilities where applicable). It has also seen a lot of real‑world use outside core (custom integration install counts, diverse hardware), which is the kind of production proof you would want before trusting a shared library. If the preference is still another official Python package or a WiiM‑branded integration path, that is a product choice—but then it is a bit contradictory to also argue for one core component without a plan to retire or modernize the legacy LinkPlay foundation. Leaving users with "old core LinkPlay + new efforts side by side" risks the same fragmentation Home Assistant already has in other domains (e.g. multiple GPIO / device families where users have to guess which integration is actually maintained). So the constructive path, in my view: either invest in bringing one modern, well‑tested shared library into the story the core integration is built on—including willingness to replace what is outdated—or accept that several paths will exist and focus on clear documentation so users know which one is recommended and supported. Happy to collaborate on technical alignment either way. |
|
Please dont throw your answers through AI. After that. Happy to engage and
have a conversation explaining how we can give Home Assistant users the
best WiiM experience possible.
…On Fri, Mar 20, 2026, 11:56 Michael Cumming ***@***.***> wrote:
*mjcumming* left a comment (home-assistant/core#148948)
<#148948 (comment)>
I think the *goal* makes sense on paper: one integration path that works
well for LinkPlay‑based gear (including WiiM) is better UX than a maze of
partially overlapping options.
The tension is what "one integration" should sit on *technically*. The *LinkPlay
code that landed in core* (migrated from the older user integration) is
in a different era than how HA does things today—e.g. *async patterns*,
structure, and ongoing maintenance burden. So "just extend that forever" is
not obviously cheaper than "replace the transport/library layer with
something actively maintained," if we are honest about long‑term fit.
*pywiim* was not written as a "WiiM‑only" fork of one OEM stack; it is
aimed at *the broader LinkPlay ecosystem* (many brands/devices) with
*device‑specific* features where the API exposes them (WiiM‑only
capabilities where applicable). It has also seen *a lot of real‑world use*
outside core (custom integration install counts, diverse hardware), which
is the kind of production proof you would want before trusting a shared
library.
If the preference is still *another* official Python package or a
WiiM‑branded integration path, that is a product choice—but then it is a
bit contradictory to *also* argue for *one* core component without a plan
to *retire or modernize* the legacy LinkPlay foundation. Leaving users
with "old core LinkPlay + new efforts side by side" risks the same
fragmentation Home Assistant already has in other domains (e.g. multiple
GPIO / device families where users have to guess which integration is
actually maintained).
So the constructive path, in my view: either *invest* in bringing one
*modern*, well‑tested shared library into the story the core integration
is built on—including willingness to *replace* what is outdated—or accept
that *several* paths will exist and focus on *clear documentation* so
users know which one is recommended and supported. Happy to collaborate on
technical alignment either way.
—
Reply to this email directly, view it on GitHub
<#148948?email_source=notifications&email_token=B7CIZVEDAT3CBIVCUWDBOU34RSXN5A5CNFSNUABFM5UWIORPF5TWS5BNNB2WEL2JONZXKZKDN5WW2ZLOOQXTIMBZGUYDSNBRGY22M4TFMFZW63VHMNXW23LFNZ2KKZLWMVXHJLDGN5XXIZLSL5RWY2LDNM#issuecomment-4095094165>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/B7CIZVGVASQYEH2DGDXTG2D4RSXN5AVCNFSM6AAAAACBXXIYWSVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHM2DAOJVGA4TIMJWGU>
.
You are receiving this because you commented.Message ID:
***@***.***>
|
Proposed change
Adds a new Home Assistant integration for WiiM devices.
This integration provides media player functionality for WiiM Streamer devices (e.g., WiiM Pro, WiiM Amp) via their UPnP/DLNA protocol and proprietary HTTP API.
Key features include:
This integration allows Home Assistant users to seamlessly integrate their WiiM devices into their smart home automations.
Type of change
Additional information
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.To help with the load of incoming pull requests: