mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Migrate UniFi Network discovery from SSDP to unifi_discovery (#168122)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
Generated
-18
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user