Skip to content

Commit fa57f72

Browse files
Linkplay2020WiimHomeemontnemeryCopilotballoob
authored
Add WiiM media player integration (#148948)
Co-authored-by: Tao Jiang <tao.jiang@linkplay.com> Co-authored-by: Erik Montnemery <erik@montnemery.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Paulus Schoutsen <balloob@gmail.com> Co-authored-by: Joostlek <joostlek@outlook.com>
1 parent 29309d1 commit fa57f72

20 files changed

+2505
-0
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""The WiiM integration."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
from urllib.parse import urlparse
7+
8+
from wiim.controller import WiimController
9+
from wiim.discovery import async_create_wiim_device
10+
from wiim.exceptions import WiimDeviceException, WiimRequestException
11+
12+
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
13+
from homeassistant.core import Event, HomeAssistant
14+
from homeassistant.exceptions import ConfigEntryNotReady
15+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
16+
from homeassistant.helpers.network import NoURLAvailableError, get_url
17+
18+
from .const import DATA_WIIM, DOMAIN, LOGGER, PLATFORMS, UPNP_PORT, WiimConfigEntry
19+
from .models import WiimData
20+
21+
DEFAULT_AVAILABILITY_POLLING_INTERVAL = 60
22+
23+
24+
async def async_setup_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool:
25+
"""Set up WiiM from a config entry.
26+
27+
This method owns the device connect/disconnect lifecycle.
28+
"""
29+
LOGGER.debug(
30+
"Setting up WiiM entry: %s (UDN: %s, Source: %s)",
31+
entry.title,
32+
entry.unique_id,
33+
entry.source,
34+
)
35+
36+
# This integration maintains shared domain-level state because:
37+
# - Multiple config entries can be loaded simultaneously.
38+
# - All WiiM devices share a single WiimController instance
39+
# to coordinate network communication and event handling.
40+
# - We also maintain a global entity_id -> UDN mapping
41+
# used for cross-entity event routing.
42+
#
43+
# The domain data must therefore be initialized once and reused
44+
# across all config entries.
45+
session = async_get_clientsession(hass)
46+
47+
if DATA_WIIM not in hass.data:
48+
hass.data[DATA_WIIM] = WiimData(controller=WiimController(session))
49+
50+
wiim_domain_data = hass.data[DATA_WIIM]
51+
controller = wiim_domain_data.controller
52+
53+
host = entry.data[CONF_HOST]
54+
upnp_location = f"http://{host}:{UPNP_PORT}/description.xml"
55+
56+
try:
57+
base_url = get_url(hass, prefer_external=False)
58+
except NoURLAvailableError as err:
59+
raise ConfigEntryNotReady("Failed to determine Home Assistant URL") from err
60+
61+
local_host = urlparse(base_url).hostname
62+
if TYPE_CHECKING:
63+
assert local_host is not None
64+
65+
try:
66+
wiim_device = await async_create_wiim_device(
67+
upnp_location,
68+
session,
69+
host=host,
70+
local_host=local_host,
71+
polling_interval=DEFAULT_AVAILABILITY_POLLING_INTERVAL,
72+
)
73+
except WiimRequestException as err:
74+
raise ConfigEntryNotReady(f"HTTP API request failed for {host}: {err}") from err
75+
except WiimDeviceException as err:
76+
raise ConfigEntryNotReady(f"Device setup failed for {host}: {err}") from err
77+
78+
await controller.add_device(wiim_device)
79+
80+
entry.runtime_data = wiim_device
81+
LOGGER.info(
82+
"WiiM device %s (UDN: %s) linked to HASS. Name: '%s', HTTP: %s, UPnP Location: %s",
83+
entry.entry_id,
84+
wiim_device.udn,
85+
wiim_device.name,
86+
host,
87+
upnp_location or "N/A",
88+
)
89+
90+
async def _async_shutdown_event_handler(event: Event) -> None:
91+
LOGGER.info(
92+
"Home Assistant stopping, disconnecting WiiM device: %s",
93+
wiim_device.name,
94+
)
95+
await wiim_device.disconnect()
96+
97+
entry.async_on_unload(
98+
hass.bus.async_listen_once(
99+
EVENT_HOMEASSISTANT_STOP, _async_shutdown_event_handler
100+
)
101+
)
102+
103+
async def _unload_entry_cleanup():
104+
"""Cleanup when unloading the config entry.
105+
106+
Removes the device from the controller and disconnects it.
107+
"""
108+
LOGGER.debug("Running unload cleanup for %s", wiim_device.name)
109+
await controller.remove_device(wiim_device.udn)
110+
await wiim_device.disconnect()
111+
112+
entry.async_on_unload(_unload_entry_cleanup)
113+
114+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
115+
return True
116+
117+
118+
async def async_unload_entry(hass: HomeAssistant, entry: WiimConfigEntry) -> bool:
119+
"""Unload a config entry."""
120+
LOGGER.info("Unloading WiiM entry: %s (UDN: %s)", entry.title, entry.unique_id)
121+
122+
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
123+
return False
124+
125+
if not hass.config_entries.async_loaded_entries(DOMAIN):
126+
hass.data.pop(DATA_WIIM)
127+
LOGGER.info("Last WiiM entry unloaded, cleaning up domain data")
128+
return True
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Config flow for WiiM integration."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
import voluptuous as vol
8+
from wiim.discovery import async_probe_wiim_device
9+
from wiim.models import WiimProbeResult
10+
11+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
12+
from homeassistant.const import CONF_HOST
13+
from homeassistant.core import HomeAssistant
14+
from homeassistant.exceptions import HomeAssistantError
15+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
16+
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
17+
18+
from .const import DOMAIN, LOGGER, UPNP_PORT
19+
20+
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
21+
22+
23+
async def _async_probe_wiim_host(hass: HomeAssistant, host: str) -> WiimProbeResult:
24+
"""Probe the given host and return WiiM device information."""
25+
session = async_get_clientsession(hass)
26+
location = f"http://{host}:{UPNP_PORT}/description.xml"
27+
LOGGER.debug("Validating UPnP device at location: %s", location)
28+
try:
29+
probe_result = await async_probe_wiim_device(
30+
location,
31+
session,
32+
host=host,
33+
)
34+
except TimeoutError as err:
35+
raise CannotConnect from err
36+
37+
if probe_result is None:
38+
raise CannotConnect
39+
return probe_result
40+
41+
42+
class WiimConfigFlow(ConfigFlow, domain=DOMAIN):
43+
"""Handle a config flow for WiiM."""
44+
45+
_discovered_info: WiimProbeResult | None = None
46+
47+
async def async_step_user(
48+
self, user_input: dict[str, Any] | None = None
49+
) -> ConfigFlowResult:
50+
"""Handle the initial step when user adds integration manually."""
51+
errors: dict[str, str] = {}
52+
if user_input is not None:
53+
host = user_input[CONF_HOST]
54+
try:
55+
device_info = await _async_probe_wiim_host(self.hass, host)
56+
except CannotConnect:
57+
errors["base"] = "cannot_connect"
58+
else:
59+
await self.async_set_unique_id(device_info.udn)
60+
self._abort_if_unique_id_configured()
61+
return self.async_create_entry(
62+
title=device_info.name,
63+
data={
64+
CONF_HOST: device_info.host,
65+
},
66+
)
67+
68+
return self.async_show_form(
69+
step_id="user",
70+
data_schema=self.add_suggested_values_to_schema(
71+
STEP_USER_DATA_SCHEMA, user_input
72+
),
73+
errors=errors,
74+
)
75+
76+
async def async_step_zeroconf(
77+
self, discovery_info: ZeroconfServiceInfo
78+
) -> ConfigFlowResult:
79+
"""Handle Zeroconf discovery."""
80+
LOGGER.debug(
81+
"Zeroconf discovery received: Name: %s, Host: %s, Port: %s, Properties: %s",
82+
discovery_info.name,
83+
discovery_info.host,
84+
discovery_info.port,
85+
discovery_info.properties,
86+
)
87+
88+
host = discovery_info.host
89+
udn_from_txt = discovery_info.properties.get("uuid")
90+
if udn_from_txt:
91+
await self.async_set_unique_id(udn_from_txt)
92+
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
93+
94+
try:
95+
device_info = await _async_probe_wiim_host(self.hass, host)
96+
except CannotConnect:
97+
return self.async_abort(reason="cannot_connect")
98+
99+
await self.async_set_unique_id(device_info.udn)
100+
self._abort_if_unique_id_configured(updates={CONF_HOST: device_info.host})
101+
102+
self._discovered_info = device_info
103+
self.context["title_placeholders"] = {"name": device_info.name}
104+
return await self.async_step_discovery_confirm()
105+
106+
async def async_step_discovery_confirm(
107+
self, user_input: dict[str, Any] | None = None
108+
) -> ConfigFlowResult:
109+
"""Handle user confirmation of discovered device."""
110+
discovered_info = self._discovered_info
111+
if user_input is not None and discovered_info is not None:
112+
return self.async_create_entry(
113+
title=discovered_info.name,
114+
data={
115+
CONF_HOST: discovered_info.host,
116+
},
117+
)
118+
119+
return self.async_show_form(
120+
step_id="discovery_confirm",
121+
description_placeholders={
122+
"name": (
123+
discovered_info.name
124+
if discovered_info is not None
125+
else "Discovered WiiM Device"
126+
)
127+
},
128+
)
129+
130+
131+
class CannotConnect(HomeAssistantError):
132+
"""Error to indicate we cannot connect."""
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Constants for the WiiM integration."""
2+
3+
import logging
4+
from typing import TYPE_CHECKING, Final
5+
6+
from homeassistant.config_entries import ConfigEntry
7+
from homeassistant.const import Platform
8+
from homeassistant.util.hass_dict import HassKey
9+
10+
if TYPE_CHECKING:
11+
from wiim import WiimDevice
12+
13+
from .models import WiimData
14+
15+
type WiimConfigEntry = ConfigEntry[WiimDevice]
16+
17+
DOMAIN: Final = "wiim"
18+
LOGGER = logging.getLogger(__package__)
19+
DATA_WIIM: HassKey[WiimData] = HassKey(DOMAIN)
20+
21+
PLATFORMS: Final[list[Platform]] = [
22+
Platform.MEDIA_PLAYER,
23+
]
24+
25+
UPNP_PORT = 49152
26+
27+
ZEROCONF_TYPE_LINKPLAY: Final = "_linkplay._tcp.local."
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Base entity for the WiiM integration."""
2+
3+
from __future__ import annotations
4+
5+
from wiim.wiim_device import WiimDevice
6+
7+
from homeassistant.helpers import device_registry as dr
8+
from homeassistant.helpers.entity import Entity
9+
10+
from .const import DOMAIN
11+
12+
13+
class WiimBaseEntity(Entity):
14+
"""Base representation of a WiiM entity."""
15+
16+
_attr_has_entity_name = True
17+
18+
def __init__(self, wiim_device: WiimDevice) -> None:
19+
"""Initialize the WiiM base entity."""
20+
self._device = wiim_device
21+
self._attr_device_info = dr.DeviceInfo(
22+
identifiers={(DOMAIN, self._device.udn)},
23+
name=self._device.name,
24+
manufacturer=self._device.manufacturer,
25+
model=self._device.model_name,
26+
sw_version=self._device.firmware_version,
27+
)
28+
if self._device.presentation_url:
29+
self._attr_device_info["configuration_url"] = self._device.presentation_url
30+
elif self._device.http_api_url:
31+
self._attr_device_info["configuration_url"] = self._device.http_api_url
32+
33+
@property
34+
def available(self) -> bool:
35+
"""Return True if entity is available."""
36+
return self._device.available
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"domain": "wiim",
3+
"name": "WiiM",
4+
"codeowners": ["@Linkplay2020"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/wiim",
7+
"integration_type": "hub",
8+
"iot_class": "local_push",
9+
"loggers": ["wiim.sdk", "async_upnp_client"],
10+
"quality_scale": "bronze",
11+
"requirements": ["wiim==0.1.0"],
12+
"zeroconf": ["_linkplay._tcp.local."]
13+
}

0 commit comments

Comments
 (0)