mirror of
https://github.com/home-assistant/core.git
synced 2026-08-29 10:21:09 -05:00
Add discovery for airOS devices (#154568)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user