Migrate UniFi Network discovery from SSDP to unifi_discovery (#168122)

Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
Raphael Hehl
2026-04-28 23:17:36 +02:00
committed by GitHub
co-authored by RaHehl
parent dfe4085189
commit 80c7e47c42
8 changed files with 169 additions and 149 deletions
+23 -30
View File
@@ -1,8 +1,8 @@
"""Config flow for UniFi Network integration.
Provides user initiated configuration flow.
Discovery of UniFi Network instances hosted on UDM and UDM Pro devices
through SSDP. Reauthentication when issue with credentials are reported.
Discovery of UniFi Network instances through unifi_discovery.
Reauthentication when issue with credentials are reported.
Configuration of options through options flow.
"""
@@ -13,7 +13,6 @@ import operator
import socket
from types import MappingProxyType
from typing import Any
from urllib.parse import urlparse
from aiounifi.interfaces.sites import Sites
import voluptuous as vol
@@ -35,11 +34,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_MODEL_DESCRIPTION,
ATTR_UPNP_SERIAL,
SsdpServiceInfo,
)
from homeassistant.helpers.typing import DiscoveryInfoType
from . import UnifiConfigEntry
from .const import (
@@ -66,12 +61,6 @@ DEFAULT_SITE_ID = "default"
DEFAULT_VERIFY_SSL = False
MODEL_PORTS = {
"UniFi Dream Machine": 443,
"UniFi Dream Machine Pro": 443,
}
class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a UniFi Network config flow."""
@@ -144,7 +133,10 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
vol.Optional(
CONF_PORT, default=self.config.get(CONF_PORT, DEFAULT_PORT)
): int,
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
vol.Optional(
CONF_VERIFY_SSL,
default=self.config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
): bool,
}
return self.async_show_form(
@@ -215,33 +207,34 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_user()
async def async_step_ssdp(
self, discovery_info: SsdpServiceInfo
async def async_step_integration_discovery(
self, discovery_info: DiscoveryInfoType
) -> ConfigFlowResult:
"""Handle a discovered UniFi device."""
parsed_url = urlparse(discovery_info.ssdp_location)
model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION]
mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL])
"""Handle discovery via unifi_discovery."""
source_ip = discovery_info["source_ip"]
if not source_ip:
return self.async_abort(reason="cannot_connect")
mac_address = format_mac(discovery_info["hw_addr"])
direct_connect_domain = discovery_info.get("direct_connect_domain")
host = direct_connect_domain or source_ip
self.config = {
CONF_HOST: parsed_url.hostname,
CONF_HOST: host,
CONF_VERIFY_SSL: bool(direct_connect_domain),
}
self._async_abort_entries_match({CONF_HOST: self.config[CONF_HOST]})
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get(CONF_HOST) in (source_ip, direct_connect_domain):
return self.async_abort(reason="already_configured")
await self.async_set_unique_id(mac_address)
self._abort_if_unique_id_configured(updates=self.config)
self.context["title_placeholders"] = {
CONF_HOST: self.config[CONF_HOST],
CONF_HOST: host,
CONF_SITE_ID: DEFAULT_SITE_ID,
}
if (port := MODEL_PORTS.get(model_description)) is not None:
self.config[CONF_PORT] = port
self.context["configuration_url"] = (
f"https://{self.config[CONF_HOST]}:{port}"
)
self.context["configuration_url"] = f"https://{host}"
return await self.async_step_user()
+2 -19
View File
@@ -3,28 +3,11 @@
"name": "UniFi Network",
"codeowners": ["@Kane610"],
"config_flow": true,
"dependencies": ["unifi_discovery"],
"documentation": "https://www.home-assistant.io/integrations/unifi",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "silver",
"requirements": ["aiounifi==90"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine Pro"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine SE"
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine Pro Max"
}
]
"requirements": ["aiounifi==90"]
}
@@ -35,7 +35,9 @@ rules:
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
discovery:
status: exempt
comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "UniFi Network site is already configured",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"configuration_updated": "Configuration updated",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
@@ -8,6 +8,7 @@ DOMAIN = "unifi_discovery"
# This must be static (not a runtime registry) because consumers may not be loaded
# when initial discovery runs — the same pattern DHCP/SSDP use with manifest matchers.
CONSUMER_MAPPING: dict[UnifiService, str] = {
UnifiService.Protect: "unifiprotect",
UnifiService.Access: "unifi_access",
UnifiService.Network: "unifi",
UnifiService.Protect: "unifiprotect",
}
-18
View File
@@ -341,24 +341,6 @@ SSDP = {
"manufacturer": "Synology",
},
],
"unifi": [
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine",
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine Pro",
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine SE",
},
{
"manufacturer": "Ubiquiti Networks",
"modelDescription": "UniFi Dream Machine Pro Max",
},
],
"unifi_discovery": [
{
"manufacturer": "Ubiquiti Networks",
+13 -5
View File
@@ -7,7 +7,7 @@ from collections.abc import Callable, Coroutine, Generator
from datetime import timedelta
from types import MappingProxyType
from typing import Any, Protocol
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
from aiounifi.models.message import MessageKey
import orjson
@@ -69,10 +69,18 @@ class WebsocketMessageMock(Protocol):
@pytest.fixture(autouse=True, name="mock_discovery")
def fixture_discovery():
"""No real network traffic allowed."""
with patch(
"homeassistant.components.unifi.config_flow._async_discover_unifi",
return_value=None,
) as mock:
with (
patch(
"homeassistant.components.unifi.config_flow._async_discover_unifi",
return_value=None,
) as mock,
patch(
"homeassistant.components.unifi_discovery.discovery.AIOUnifiScanner",
return_value=MagicMock(
async_scan=AsyncMock(return_value=[]), found_devices=[]
),
),
):
yield mock
+125 -75
View File
@@ -32,7 +32,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.helpers.device_registry import format_mac
from .conftest import ConfigEntryFactoryType
@@ -492,90 +492,161 @@ async def test_simple_option_flow(
}
async def test_form_ssdp(hass: HomeAssistant) -> None:
"""Test we get the form with ssdp source."""
async def test_discover_unifi_positive(hass: HomeAssistant) -> None:
"""Verify positive run of UniFi discovery."""
with patch("socket.gethostbyname", return_value="192.168.1.1"):
assert await _async_discover_unifi(hass) == "192.168.1.1"
async def test_discover_unifi_negative(hass: HomeAssistant) -> None:
"""Verify negative run of UniFi discovery."""
with patch("socket.gethostbyname", side_effect=socket.gaierror):
assert await _async_discover_unifi(hass) is None
INTEGRATION_DISCOVERY_INFO = {
"source_ip": "10.0.0.1",
"hw_addr": "e0:63:da:20:14:a9",
"hostname": "UniFi-Dream-Machine",
"platform": "UCG-Ultra",
"direct_connect_domain": "x.ui.direct",
}
async def test_flow_integration_discovery(hass: HomeAssistant) -> None:
"""Test we get the form with integration discovery source."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://192.168.208.1:41417/rootDesc.xml",
upnp={
"friendlyName": "UniFi Dream Machine",
"modelDescription": "UniFi Dream Machine Pro",
"serialNumber": "e0:63:da:20:14:a9",
},
),
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=INTEGRATION_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {}
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert (
flows[0].get("context", {}).get("configuration_url")
== "https://192.168.208.1:443"
)
context = next(
flow["context"]
for flow in hass.config_entries.flow.async_progress()
if flow["flow_id"] == result["flow_id"]
)
assert context["title_placeholders"] == {
"host": "192.168.208.1",
"host": "x.ui.direct",
"site": "default",
}
assert context["configuration_url"] == "https://x.ui.direct"
@pytest.mark.usefixtures("config_entry")
async def test_form_ssdp_aborts_if_host_already_exists(hass: HomeAssistant) -> None:
async def test_flow_integration_discovery_aborts_if_host_already_exists(
hass: HomeAssistant,
) -> None:
"""Test we abort if the host is already configured."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://1.2.3.4:1234/rootDesc.xml",
upnp={
"friendlyName": "UniFi Dream Machine",
"modelDescription": "UniFi Dream Machine Pro",
"serialNumber": "e0:63:da:20:14:a9",
},
),
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={
**INTEGRATION_DISCOVERY_INFO,
"source_ip": "1.2.3.4",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_flow_integration_discovery_uses_direct_connect_domain(
hass: HomeAssistant,
) -> None:
"""Test discovery prefers direct_connect_domain over source_ip."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=INTEGRATION_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
context = next(
flow["context"]
for flow in hass.config_entries.flow.async_progress()
if flow["flow_id"] == result["flow_id"]
)
assert context["title_placeholders"] == {
"host": "x.ui.direct",
"site": "default",
}
schema_defaults = {
marker.schema: marker.default()
for marker in result["data_schema"].schema
if hasattr(marker, "default") and callable(marker.default)
}
assert schema_defaults[CONF_HOST] == "x.ui.direct"
assert schema_defaults[CONF_VERIFY_SSL] is True
@pytest.mark.usefixtures("config_entry")
async def test_form_ssdp_aborts_if_serial_already_exists(hass: HomeAssistant) -> None:
"""Test we abort if the serial is already configured."""
async def test_flow_integration_discovery_aborts_on_direct_connect_host(
hass: HomeAssistant,
) -> None:
"""Test we abort if the direct connect domain matches a configured host."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://1.2.3.4:1234/rootDesc.xml",
upnp={
"friendlyName": "UniFi Dream Machine",
"modelDescription": "UniFi Dream Machine Pro",
"serialNumber": "1",
},
),
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={
**INTEGRATION_DISCOVERY_INFO,
"source_ip": "10.0.0.1",
"direct_connect_domain": "1.2.3.4",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> None:
"""Test we can still setup if there is an ignored never configured entry."""
async def test_flow_integration_discovery_updates_existing_entry_on_rediscovery(
hass: HomeAssistant,
) -> None:
"""Test that an existing entry's host is refreshed when rediscovered with the same MAC."""
old_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=format_mac(INTEGRATION_DISCOVERY_INFO["hw_addr"]),
data={
CONF_HOST: "old.host",
CONF_VERIFY_SSL: False,
},
)
old_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=INTEGRATION_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
assert old_entry.data[CONF_HOST] == "x.ui.direct"
assert old_entry.data[CONF_VERIFY_SSL] is True
async def test_flow_integration_discovery_aborts_without_source_ip(
hass: HomeAssistant,
) -> None:
"""Test we abort discovery when source_ip is missing."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data={
**INTEGRATION_DISCOVERY_INFO,
"source_ip": None,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_flow_integration_discovery_gets_form_with_ignored_entry(
hass: HomeAssistant,
) -> None:
"""Test we can still set up if there is an ignored never configured entry."""
entry = MockConfigEntry(
domain=DOMAIN,
data={"not_controller_key": None},
@@ -584,17 +655,8 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_SSDP},
data=SsdpServiceInfo(
ssdp_usn="mock_usn",
ssdp_st="mock_st",
ssdp_location="http://1.2.3.4:1234/rootDesc.xml",
upnp={
"friendlyName": "UniFi Dream Machine New",
"modelDescription": "UniFi Dream Machine Pro",
"serialNumber": "1",
},
),
context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
data=INTEGRATION_DISCOVERY_INFO,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
@@ -605,18 +667,6 @@ async def test_form_ssdp_gets_form_with_ignored_entry(hass: HomeAssistant) -> No
if flow["flow_id"] == result["flow_id"]
)
assert context["title_placeholders"] == {
"host": "1.2.3.4",
"host": "x.ui.direct",
"site": "default",
}
async def test_discover_unifi_positive(hass: HomeAssistant) -> None:
"""Verify positive run of UniFi discovery."""
with patch("socket.gethostbyname", return_value=True):
assert await _async_discover_unifi(hass)
async def test_discover_unifi_negative(hass: HomeAssistant) -> None:
"""Verify negative run of UniFi discovery."""
with patch("socket.gethostbyname", side_effect=socket.gaierror):
assert await _async_discover_unifi(hass) is None