Add discovery for airOS devices (#154568)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Tom
2026-02-23 18:59:50 +01:00
committed by GitHub
co-authored by Copilot
parent ea7732e9ee
commit 6570b413d4
5 changed files with 586 additions and 50 deletions
+200 -5
View File
@@ -2,16 +2,20 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
import logging
from typing import Any
from airos.discovery import airos_discover_devices
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSEndpointError,
AirOSKeyDataMissingError,
AirOSListenerError,
)
import voluptuous as vol
@@ -36,15 +40,27 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .const import (
DEFAULT_SSL,
DEFAULT_USERNAME,
DEFAULT_VERIFY_SSL,
DEVICE_NAME,
DOMAIN,
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import AirOS8
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
# Discovery duration in seconds, airOS announces every 20 seconds
DISCOVER_INTERVAL: int = 30
STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_USERNAME, default="ubnt"): str,
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
@@ -58,6 +74,10 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_MANUAL_DATA_SCHEMA = STEP_DISCOVERY_DATA_SCHEMA.extend(
{vol.Required(CONF_HOST): str}
)
class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ubiquiti airOS."""
@@ -65,14 +85,29 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 2
MINOR_VERSION = 1
_discovery_task: asyncio.Task | None = None
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.airos_device: AirOS8
self.errors: dict[str, str] = {}
self.discovered_devices: dict[str, dict[str, Any]] = {}
self.discovery_abort_reason: str | None = None
self.selected_device_info: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self.errors = {}
return self.async_show_menu(
step_id="user", menu_options=["discovery", "manual"]
)
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the manual input of host and credentials."""
self.errors = {}
@@ -84,7 +119,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
data=validated_info["data"],
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=self.errors
step_id="manual", data_schema=STEP_MANUAL_DATA_SCHEMA, errors=self.errors
)
async def _validate_and_get_device_info(
@@ -220,3 +255,163 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=self.errors,
)
async def async_step_discovery(
self,
discovery_info: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Start the discovery process."""
if self._discovery_task and self._discovery_task.done():
self._discovery_task = None
# Handle appropriate 'errors' as abort through progress_done
if self.discovery_abort_reason:
return self.async_show_progress_done(
next_step_id=self.discovery_abort_reason
)
# Abort through progress_done if no devices were found
if not self.discovered_devices:
_LOGGER.debug(
"No (new or unconfigured) airOS devices found during discovery"
)
return self.async_show_progress_done(
next_step_id="discovery_no_devices"
)
# Skip selecting a device if only one new/unconfigured device was found
if len(self.discovered_devices) == 1:
self.selected_device_info = list(self.discovered_devices.values())[0]
return self.async_show_progress_done(next_step_id="configure_device")
return self.async_show_progress_done(next_step_id="select_device")
if not self._discovery_task:
self.discovered_devices = {}
self._discovery_task = self.hass.async_create_task(
self._async_run_discovery_with_progress()
)
# Show the progress bar and wait for discovery to complete
return self.async_show_progress(
step_id="discovery",
progress_action="discovering",
progress_task=self._discovery_task,
description_placeholders={"seconds": str(DISCOVER_INTERVAL)},
)
async def async_step_select_device(
self,
discovery_info: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Select a discovered device."""
if discovery_info is not None:
selected_mac = discovery_info[MAC_ADDRESS]
self.selected_device_info = self.discovered_devices[selected_mac]
return await self.async_step_configure_device()
list_options = {
mac: f"{device.get(HOSTNAME, mac)} ({device.get(IP_ADDRESS, DEVICE_NAME)})"
for mac, device in self.discovered_devices.items()
}
return self.async_show_form(
step_id="select_device",
data_schema=vol.Schema({vol.Required(MAC_ADDRESS): vol.In(list_options)}),
)
async def async_step_configure_device(
self,
user_input: dict[str, Any] | None = None,
) -> ConfigFlowResult:
"""Configure the selected device."""
self.errors = {}
if user_input is not None:
config_data = {
**user_input,
CONF_HOST: self.selected_device_info[IP_ADDRESS],
}
validated_info = await self._validate_and_get_device_info(config_data)
if validated_info:
return self.async_create_entry(
title=validated_info["title"],
data=validated_info["data"],
)
device_name = self.selected_device_info.get(
HOSTNAME, self.selected_device_info.get(IP_ADDRESS, DEVICE_NAME)
)
return self.async_show_form(
step_id="configure_device",
data_schema=STEP_DISCOVERY_DATA_SCHEMA,
errors=self.errors,
description_placeholders={"device_name": device_name},
)
async def _async_run_discovery_with_progress(self) -> None:
"""Run discovery with an embedded progress update loop."""
progress_bar = self.hass.async_create_task(self._async_update_progress_bar())
known_mac_addresses = {
entry.unique_id.lower()
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.unique_id
}
try:
devices = await airos_discover_devices(DISCOVER_INTERVAL)
except AirOSEndpointError:
self.discovery_abort_reason = "discovery_detect_error"
except AirOSListenerError:
self.discovery_abort_reason = "discovery_listen_error"
except Exception:
self.discovery_abort_reason = "discovery_failed"
_LOGGER.exception("An error occurred during discovery")
else:
self.discovered_devices = {
mac_addr: info
for mac_addr, info in devices.items()
if mac_addr.lower() not in known_mac_addresses
}
_LOGGER.debug(
"Discovery task finished. Found %s new devices",
len(self.discovered_devices),
)
finally:
progress_bar.cancel()
async def _async_update_progress_bar(self) -> None:
"""Update progress bar every second."""
try:
for i in range(DISCOVER_INTERVAL):
progress = (i + 1) / DISCOVER_INTERVAL
self.async_update_progress(progress)
await asyncio.sleep(1)
except asyncio.CancelledError:
pass
async def async_step_discovery_no_devices(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery finds no (unconfigured) devices."""
return self.async_abort(reason="no_devices_found")
async def async_step_discovery_listen_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery is unable to listen on the port."""
return self.async_abort(reason="listen_error")
async def async_step_discovery_detect_error(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery receives incorrect broadcasts."""
return self.async_abort(reason="detect_error")
async def async_step_discovery_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort if discovery fails for other reasons."""
return self.async_abort(reason="discovery_failed")
+7
View File
@@ -12,3 +12,10 @@ DEFAULT_VERIFY_SSL = False
DEFAULT_SSL = True
SECTION_ADVANCED_SETTINGS = "advanced_settings"
# Discovery related
DEFAULT_USERNAME = "ubnt"
HOSTNAME = "hostname"
IP_ADDRESS = "ip_address"
MAC_ADDRESS = "mac_address"
DEVICE_NAME = "airOS device"
+62 -16
View File
@@ -2,6 +2,10 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"detect_error": "Unable to process discovered devices data, check the documentation for supported devices",
"discovery_failed": "Unable to start discovery, check logs for details",
"listen_error": "Unable to start listening for devices",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Re-authentication should be used for the same device not a new one"
@@ -13,37 +17,36 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "Ubiquiti airOS device",
"progress": {
"connecting": "Connecting to the airOS device",
"discovering": "Listening for any airOS devices for {seconds} seconds"
},
"step": {
"reauth_confirm": {
"configure_device": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
}
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::user::data_description::password%]"
"password": "[%key:component::airos::config::step::manual::data_description::password%]",
"username": "[%key:component::airos::config::step::manual::data_description::username%]"
},
"description": "Enter the username and password for {device_name}",
"sections": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::user::sections::advanced_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::user::sections::advanced_settings::name%]"
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
"user": {
"manual": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"password": "[%key:common::config_flow::data::password%]",
@@ -67,6 +70,49 @@
"name": "Advanced settings"
}
}
},
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
}
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
},
"sections": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
"select_device": {
"data": {
"mac_address": "Select the device to configure"
},
"data_description": {
"mac_address": "Select the device MAC address"
}
},
"user": {
"menu_options": {
"discovery": "Listen for airOS devices on the network",
"manual": "Manually configure airOS device"
}
}
}
},
+12 -2
View File
@@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from airos.airos8 import AirOS8Data
import pytest
from homeassistant.components.airos.const import DOMAIN
from homeassistant.components.airos.const import DEFAULT_USERNAME, DOMAIN
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from tests.common import MockConfigEntry, load_json_object_fixture
@@ -60,7 +60,17 @@ def mock_config_entry() -> MockConfigEntry:
data={
CONF_HOST: "1.1.1.1",
CONF_PASSWORD: "test-password",
CONF_USERNAME: "ubnt",
CONF_USERNAME: DEFAULT_USERNAME,
},
unique_id="01:23:45:67:89:AB",
)
@pytest.fixture
def mock_discovery_method() -> Generator[AsyncMock]:
"""Mock the internal discovery method of the config flow."""
with patch(
"homeassistant.components.airos.config_flow.airos_discover_devices",
new_callable=AsyncMock,
) as mock_method:
yield mock_method
+305 -27
View File
@@ -5,12 +5,23 @@ from unittest.mock import AsyncMock
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
AirOSEndpointError,
AirOSKeyDataMissingError,
AirOSListenerError,
)
import pytest
import voluptuous as vol
from homeassistant.components.airos.const import DOMAIN, SECTION_ADVANCED_SETTINGS
from homeassistant.components.airos.const import (
DEFAULT_USERNAME,
DOMAIN,
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER
from homeassistant.const import (
CONF_HOST,
@@ -28,39 +39,64 @@ NEW_PASSWORD = "new_password"
REAUTH_STEP = "reauth_confirm"
RECONFIGURE_STEP = "reconfigure"
MOCK_ADVANCED_SETTINGS = {
CONF_SSL: True,
CONF_VERIFY_SSL: False,
}
MOCK_CONFIG = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: False,
},
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
}
MOCK_CONFIG_REAUTH = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "wrong-password",
}
MOCK_DISC_DEV1 = {
MAC_ADDRESS: "00:11:22:33:44:55",
IP_ADDRESS: "192.168.1.100",
HOSTNAME: "Test-Device-1",
}
MOCK_DISC_DEV2 = {
MAC_ADDRESS: "AA:BB:CC:DD:EE:FF",
IP_ADDRESS: "192.168.1.101",
HOSTNAME: "Test-Device-2",
}
MOCK_DISC_EXISTS = {
MAC_ADDRESS: "01:23:45:67:89:AB",
IP_ADDRESS: "192.168.1.102",
HOSTNAME: "Existing-Device",
}
async def test_form_creates_entry(
async def test_manual_flow_creates_entry(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_airos_client: AsyncMock,
ap_fixture: dict[str, Any],
) -> None:
"""Test we get the form and create the appropriate entry."""
"""Test we get the user form and create the appropriate entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
assert result["type"] is FlowResultType.MENU
assert "manual" in result["menu_options"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
result["flow_id"], {"next_step_id": "manual"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], MOCK_CONFIG
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@@ -73,22 +109,26 @@ async def test_form_creates_entry(
async def test_form_duplicate_entry(
hass: HomeAssistant,
mock_airos_client: AsyncMock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test the form does not allow duplicate entries."""
mock_config_entry.add_to_hass(hass)
mock_entry = MockConfigEntry(
domain=DOMAIN,
unique_id="01:23:45:67:89:AB",
data=MOCK_CONFIG,
)
mock_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
flow_start = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
menu = await hass.config_entries.flow.async_configure(
flow_start["flow_id"], {"next_step_id": "manual"}
)
assert result["type"] is FlowResultType.FORM
assert not result["errors"]
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
menu["flow_id"], MOCK_CONFIG
)
assert result["type"] is FlowResultType.ABORT
@@ -98,6 +138,8 @@ async def test_form_duplicate_entry(
@pytest.mark.parametrize(
("exception", "error"),
[
(AirOSConnectionAuthenticationError, "invalid_auth"),
(AirOSConnectionSetupError, "cannot_connect"),
(AirOSDeviceConnectionError, "cannot_connect"),
(AirOSKeyDataMissingError, "key_data_missing"),
(Exception, "unknown"),
@@ -113,13 +155,17 @@ async def test_form_exception_handling(
"""Test we handle exceptions."""
mock_airos_client.login.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
flow_start = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
menu = await hass.config_entries.flow.async_configure(
flow_start["flow_id"], {"next_step_id": "manual"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
menu["flow_id"], MOCK_CONFIG
)
assert result["type"] is FlowResultType.FORM
@@ -402,3 +448,235 @@ async def test_reconfigure_unique_id_mismatch(
updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
== MOCK_CONFIG[SECTION_ADVANCED_SETTINGS][CONF_SSL]
)
async def test_discover_flow_no_devices_found(
hass: HomeAssistant, mock_discovery_method
) -> None:
"""Test discovery flow aborts when no devices are found."""
mock_discovery_method.return_value = {}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "discovery"}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "discovery"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
async def test_discover_flow_one_device_found(
hass: HomeAssistant, mock_discovery_method, mock_airos_client, mock_setup_entry
) -> None:
"""Test discovery flow goes straight to credentials when one device is found."""
mock_discovery_method.return_value = {MOCK_DISC_DEV1[MAC_ADDRESS]: MOCK_DISC_DEV1}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "discovery"}
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# With only one device, the flow should skip the select step and
# go directly to configure_device.
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_device"
assert result["description_placeholders"]["device_name"] == MOCK_DISC_DEV1[HOSTNAME]
# Provide credentials and complete the flow
mock_airos_client.status.return_value.derived.mac = MOCK_DISC_DEV1[MAC_ADDRESS]
mock_airos_client.status.return_value.host.hostname = MOCK_DISC_DEV1[HOSTNAME]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_DISC_DEV1[HOSTNAME]
assert result["data"][CONF_HOST] == MOCK_DISC_DEV1[IP_ADDRESS]
async def test_discover_flow_multiple_devices_found(
hass: HomeAssistant, mock_discovery_method, mock_airos_client, mock_setup_entry
) -> None:
"""Test discovery flow with multiple devices found, requiring a selection step."""
mock_discovery_method.return_value = {
MOCK_DISC_DEV1[MAC_ADDRESS]: MOCK_DISC_DEV1,
MOCK_DISC_DEV2[MAC_ADDRESS]: MOCK_DISC_DEV2,
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert "discovery" in result["menu_options"]
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "discovery"}
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["step_id"] == "discovery"
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "select_device"
expected_options = {
MOCK_DISC_DEV1[MAC_ADDRESS]: (
f"{MOCK_DISC_DEV1[HOSTNAME]} ({MOCK_DISC_DEV1[IP_ADDRESS]})"
),
MOCK_DISC_DEV2[MAC_ADDRESS]: (
f"{MOCK_DISC_DEV2[HOSTNAME]} ({MOCK_DISC_DEV2[IP_ADDRESS]})"
),
}
actual_options = result["data_schema"].schema[vol.Required(MAC_ADDRESS)].container
assert actual_options == expected_options
# Select one of the devices
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {MAC_ADDRESS: MOCK_DISC_DEV1[MAC_ADDRESS]}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_device"
assert result["description_placeholders"]["device_name"] == MOCK_DISC_DEV1[HOSTNAME]
# Provide credentials and complete the flow
mock_airos_client.status.return_value.derived.mac = MOCK_DISC_DEV1[MAC_ADDRESS]
mock_airos_client.status.return_value.host.hostname = MOCK_DISC_DEV1[HOSTNAME]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MOCK_DISC_DEV1[HOSTNAME]
assert result["data"][CONF_HOST] == MOCK_DISC_DEV1[IP_ADDRESS]
async def test_discover_flow_with_existing_device(
hass: HomeAssistant, mock_discovery_method, mock_airos_client
) -> None:
"""Test that discovery ignores devices that are already configured."""
# Add a mock config entry for an existing device
mock_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=MOCK_DISC_EXISTS[MAC_ADDRESS],
data=MOCK_CONFIG,
)
mock_entry.add_to_hass(hass)
# Mock discovery to find both a new device and the existing one
mock_discovery_method.return_value = {
MOCK_DISC_DEV1[MAC_ADDRESS]: MOCK_DISC_DEV1,
MOCK_DISC_EXISTS[MAC_ADDRESS]: MOCK_DISC_EXISTS,
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "discovery"}
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
# The flow should proceed with only the new device
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_device"
assert result["description_placeholders"]["device_name"] == MOCK_DISC_DEV1[HOSTNAME]
@pytest.mark.parametrize(
("exception", "reason"),
[
(AirOSEndpointError, "detect_error"),
(AirOSListenerError, "listen_error"),
(Exception, "discovery_failed"),
],
)
async def test_discover_flow_discovery_exceptions(
hass: HomeAssistant,
mock_discovery_method,
exception: Exception,
reason: str,
) -> None:
"""Test discovery flow aborts on various discovery exceptions."""
mock_discovery_method.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "discovery"}
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == reason
async def test_configure_device_flow_exceptions(
hass: HomeAssistant, mock_discovery_method, mock_airos_client
) -> None:
"""Test configure_device step handles authentication and connection exceptions."""
mock_discovery_method.return_value = {MOCK_DISC_DEV1[MAC_ADDRESS]: MOCK_DISC_DEV1}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"next_step_id": "discovery"}
)
mock_airos_client.login.side_effect = AirOSConnectionAuthenticationError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: "wrong-user",
CONF_PASSWORD: "wrong-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "invalid_auth"}
mock_airos_client.login.side_effect = AirOSDeviceConnectionError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "some-password",
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}