-
Notifications
You must be signed in to change notification settings - Fork 37.2k
Add WiiM media player integration #148948
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 138 commits
c96d5dc
e2cebf3
56b3eb1
f6858d2
66b11e4
8439541
79ef189
879843c
7b76d8d
644c421
cb233bf
b9fc4f9
263a7cc
4a4e053
c58f679
818b69e
529c7d4
34bc01d
0ce9899
d5ceb37
5a78331
9edf8be
68366d2
829ac69
ec724f1
b4414f2
fa3cfab
800353e
2e4ddce
71a389f
4e00376
8b96e36
224599d
c260139
5d74b6e
f844755
307b131
9d5642a
152bebd
b88adab
4ba50a8
d105685
bd43490
c9d0fdd
debfd44
04f1f29
1db9d7e
97ceee0
6866753
b91d8b7
3630fb1
9726a48
d050c42
5db4e1d
220b2f0
249ebff
a65dcc2
2dee4c4
392e79d
7149183
9ce2e50
ca55cb7
a0605c7
f04edec
a9a4aab
d8bfcea
8d4c253
b30df25
437be2d
99ad8fe
a78155f
ced0fc6
8f11167
b6f70d4
b279909
53b17ff
85c8564
797f26f
364b068
f85ad64
b17ca64
60de76f
83498f6
2ee2d40
ba3f6f5
269cda7
6cdf398
d455587
9f80815
809a711
7067ba1
d14ffda
006bd97
30c4549
c70f06a
89f15e1
5284c80
97f633c
87b1b3e
2911159
a0f05f6
7ae6db9
0100cd6
c25ce9e
43d78c9
4ab2bad
de4129d
7c2adf8
9c62ffc
8604a44
3c74bac
6be7afa
62f32f8
7526985
479728e
543d58b
59663fb
9ba70a5
9dcf973
3743c8c
1ce527d
1ba8a20
ed70ee6
661ae29
70122e3
9afe43a
28f953a
3b26333
1b34058
228d148
50be3cd
e61b088
0419ec6
0a74562
5342725
2119294
b50fa42
f40cb75
9e6da69
c1a2f33
7988c4d
287a649
637ac7c
b0220c4
e78036a
99a0424
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| """The WiiM integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING | ||
| from urllib.parse import urlparse | ||
|
|
||
| from wiim.controller import WiimController | ||
| from wiim.discovery import async_create_wiim_device | ||
| from wiim.exceptions import WiimDeviceException, WiimRequestException | ||
|
|
||
| from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP | ||
| from homeassistant.core import Event, HomeAssistant | ||
| from homeassistant.exceptions import ConfigEntryNotReady | ||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||
| from homeassistant.helpers.network import NoURLAvailableError, get_url | ||
|
|
||
| from .const import DATA_WIIM, DOMAIN, LOGGER, PLATFORMS, UPNP_PORT, WiimConfigEntry | ||
| from .models import WiimData | ||
|
|
||
| DEFAULT_AVAILABILITY_POLLING_INTERVAL = 60 | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool: | ||
| """Set up WiiM from a config entry. | ||
|
|
||
| This method owns the device connect/disconnect lifecycle. | ||
| """ | ||
| LOGGER.debug( | ||
| "Setting up WiiM entry: %s (UDN: %s, Source: %s)", | ||
| entry.title, | ||
| entry.unique_id, | ||
| entry.source, | ||
| ) | ||
|
|
||
| # This integration maintains shared domain-level state because: | ||
| # - Multiple config entries can be loaded simultaneously. | ||
| # - All WiiM devices share a single WiimController instance | ||
| # to coordinate network communication and event handling. | ||
| # - We also maintain a global entity_id -> UDN mapping | ||
| # used for cross-entity event routing. | ||
| # | ||
| # The domain data must therefore be initialized once and reused | ||
| # across all config entries. | ||
| session = async_get_clientsession(hass) | ||
|
|
||
| if DATA_WIIM not in hass.data: | ||
| hass.data[DATA_WIIM] = WiimData(controller=WiimController(session)) | ||
|
|
||
| wiim_domain_data = hass.data[DATA_WIIM] | ||
| controller = wiim_domain_data.controller | ||
|
|
||
| host = entry.data[CONF_HOST] | ||
| upnp_location = f"http://{host}:{UPNP_PORT}/description.xml" | ||
|
|
||
| try: | ||
| base_url = get_url(hass, prefer_external=False) | ||
| except NoURLAvailableError as err: | ||
| raise ConfigEntryNotReady("Failed to determine Home Assistant URL") from err | ||
|
|
||
| local_host = urlparse(base_url).hostname | ||
| if TYPE_CHECKING: | ||
| assert local_host is not None | ||
|
|
||
|
Comment on lines
+61
to
+64
|
||
| try: | ||
| wiim_device = await async_create_wiim_device( | ||
| upnp_location, | ||
| session, | ||
| host=host, | ||
| local_host=local_host, | ||
| polling_interval=DEFAULT_AVAILABILITY_POLLING_INTERVAL, | ||
| ) | ||
| except WiimRequestException as err: | ||
| raise ConfigEntryNotReady(f"HTTP API request failed for {host}: {err}") from err | ||
| except WiimDeviceException as err: | ||
| raise ConfigEntryNotReady(f"Device setup failed for {host}: {err}") from err | ||
|
|
||
| await controller.add_device(wiim_device) | ||
|
|
||
| entry.runtime_data = wiim_device | ||
| 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, | ||
|
Comment on lines
+81
to
+86
|
||
| upnp_location or "N/A", | ||
| ) | ||
|
|
||
| async def _async_shutdown_event_handler(event: Event) -> None: | ||
| LOGGER.info( | ||
| "Home Assistant stopping, disconnecting WiiM device: %s", | ||
| wiim_device.name, | ||
| ) | ||
| await wiim_device.disconnect() | ||
|
|
||
| entry.async_on_unload( | ||
| hass.bus.async_listen_once( | ||
| EVENT_HOMEASSISTANT_STOP, _async_shutdown_event_handler | ||
| ) | ||
| ) | ||
emontnemery marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| async def _unload_entry_cleanup(): | ||
| """Cleanup when unloading the config entry. | ||
|
|
||
| Removes the device from the controller and disconnects it. | ||
| """ | ||
| LOGGER.debug("Running unload cleanup for %s", wiim_device.name) | ||
| await controller.remove_device(wiim_device.udn) | ||
| await wiim_device.disconnect() | ||
|
|
||
| entry.async_on_unload(_unload_entry_cleanup) | ||
|
Comment on lines
+103
to
+112
|
||
|
|
||
| await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) | ||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| LOGGER.info("Unloading WiiM entry: %s (UDN: %s)", entry.title, entry.unique_id) | ||
|
|
||
| if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): | ||
| return False | ||
|
|
||
| if not hass.config_entries.async_loaded_entries(DOMAIN): | ||
| hass.data.pop(DATA_WIIM) | ||
| LOGGER.info("Last WiiM entry unloaded, cleaning up domain data") | ||
| return True | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,132 @@ | ||||||
| """Config flow for WiiM integration.""" | ||||||
|
|
||||||
| from __future__ import annotations | ||||||
|
|
||||||
| from typing import Any | ||||||
|
|
||||||
| import voluptuous as vol | ||||||
| from wiim.discovery import async_probe_wiim_device | ||||||
| from wiim.models import WiimProbeResult | ||||||
|
|
||||||
| from homeassistant.config_entries import ConfigFlow, ConfigFlowResult | ||||||
| from homeassistant.const import CONF_HOST | ||||||
| from homeassistant.core import HomeAssistant | ||||||
| from homeassistant.exceptions import HomeAssistantError | ||||||
| from homeassistant.helpers.aiohttp_client import async_get_clientsession | ||||||
| from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo | ||||||
|
|
||||||
| from .const import DOMAIN, LOGGER, UPNP_PORT | ||||||
|
|
||||||
| STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) | ||||||
|
|
||||||
|
|
||||||
| 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 | ||||||
|
Comment on lines
+23
to
+39
|
||||||
|
|
||||||
|
|
||||||
| class WiimConfigFlow(ConfigFlow, domain=DOMAIN): | ||||||
| """Handle a config flow for WiiM.""" | ||||||
|
|
||||||
emontnemery marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| _discovered_info: WiimProbeResult | None = None | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't assign it here, just make it a type annotation:
Suggested change
|
||||||
|
|
||||||
|
Comment on lines
+42
to
+46
|
||||||
| async def async_step_user( | ||||||
| self, user_input: dict[str, Any] | None = None | ||||||
| ) -> ConfigFlowResult: | ||||||
| """Handle the initial step when user adds integration manually.""" | ||||||
| errors: dict[str, str] = {} | ||||||
| if user_input is not None: | ||||||
| host = user_input[CONF_HOST] | ||||||
| try: | ||||||
| device_info = await _async_probe_wiim_host(self.hass, host) | ||||||
| except CannotConnect: | ||||||
| errors["base"] = "cannot_connect" | ||||||
| else: | ||||||
|
||||||
| else: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is recommended to keep this else.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
discovered_info can't be None
Copilot
AI
Feb 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Commented-out code should be removed. Line 233 contains a commented-out call to _set_confirm_only() that should either be uncommented if needed or removed entirely.
| return self.async_show_form( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The commented-out _set_confirm_only() call has been removed to clean up the code.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| """Constants for the WiiM integration.""" | ||
|
|
||
| import logging | ||
| from typing import TYPE_CHECKING, Final | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import Platform | ||
| from homeassistant.util.hass_dict import HassKey | ||
|
|
||
| if TYPE_CHECKING: | ||
| from wiim import WiimDevice | ||
|
|
||
| from .models import WiimData | ||
|
|
||
| type WiimConfigEntry = ConfigEntry[WiimDevice] | ||
|
|
||
| DOMAIN: Final = "wiim" | ||
| LOGGER = logging.getLogger(__package__) | ||
| DATA_WIIM: HassKey[WiimData] = HassKey(DOMAIN) | ||
|
|
||
| PLATFORMS: Final[list[Platform]] = [ | ||
| Platform.MEDIA_PLAYER, | ||
| ] | ||
|
|
||
| UPNP_PORT = 49152 | ||
|
|
||
| ZEROCONF_TYPE_LINKPLAY: Final = "_linkplay._tcp.local." |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| """Base entity for the WiiM integration.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from wiim.wiim_device import WiimDevice | ||
|
|
||
| from homeassistant.helpers import device_registry as dr | ||
| from homeassistant.helpers.entity import Entity | ||
|
|
||
| from .const import DOMAIN | ||
|
|
||
|
|
||
| class WiimBaseEntity(Entity): | ||
| """Base representation of a WiiM entity.""" | ||
|
|
||
| _attr_has_entity_name = True | ||
|
|
||
| def __init__(self, wiim_device: WiimDevice) -> None: | ||
| """Initialize the WiiM base entity.""" | ||
| self._device = wiim_device | ||
| self._attr_device_info = dr.DeviceInfo( | ||
| identifiers={(DOMAIN, self._device.udn)}, | ||
| name=self._device.name, | ||
| manufacturer=self._device.manufacturer, | ||
| model=self._device.model_name, | ||
| sw_version=self._device.firmware_version, | ||
| ) | ||
| if self._device.presentation_url: | ||
| self._attr_device_info["configuration_url"] = self._device.presentation_url | ||
| elif self._device.http_api_url: | ||
| self._attr_device_info["configuration_url"] = self._device.http_api_url | ||
|
|
||
| @property | ||
| def available(self) -> bool: | ||
| """Return True if entity is available.""" | ||
| return self._device.available |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| { | ||
| "domain": "wiim", | ||
| "name": "WiiM", | ||
| "codeowners": ["@Linkplay2020"], | ||
| "config_flow": true, | ||
| "documentation": "https://www.home-assistant.io/integrations/wiim", | ||
| "integration_type": "hub", | ||
| "iot_class": "local_push", | ||
| "loggers": ["wiim.sdk", "async_upnp_client"], | ||
| "quality_scale": "bronze", | ||
| "requirements": ["wiim==0.1.0"], | ||
| "zeroconf": ["_linkplay._tcp.local."] | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.