diff --git a/CODEOWNERS b/CODEOWNERS index ec098523744..e3a7c5e7327 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -364,6 +364,7 @@ homeassistant/components/roku/* @ctalkington homeassistant/components/roomba/* @pschmitt @cyr-ius @shenxn homeassistant/components/roon/* @pavoni homeassistant/components/rpi_power/* @shenxn @swetoast +homeassistant/components/ruckus_unleashed/* @gabe565 homeassistant/components/safe_mode/* @home-assistant/core homeassistant/components/saj/* @fredericvl homeassistant/components/salt/* @bjornorri diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py new file mode 100644 index 00000000000..5547e6ebb70 --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -0,0 +1,71 @@ +"""The Ruckus Unleashed integration.""" +import asyncio + +from pyruckus import Ruckus +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import COORDINATOR, DOMAIN, PLATFORMS, UNDO_UPDATE_LISTENERS +from .coordinator import RuckusUnleashedDataUpdateCoordinator + +CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({})}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Ruckus Unleashed component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ruckus Unleashed from a config entry.""" + try: + ruckus = await hass.async_add_executor_job( + Ruckus, + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + except ConnectionError as error: + raise ConfigEntryNotReady from error + + coordinator = RuckusUnleashedDataUpdateCoordinator(hass, ruckus=ruckus) + + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = { + COORDINATOR: coordinator, + UNDO_UPDATE_LISTENERS: [], + } + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + for listener in hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS]: + listener() + + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/ruckus_unleashed/config_flow.py b/homeassistant/components/ruckus_unleashed/config_flow.py new file mode 100644 index 00000000000..34fca5b1c4a --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -0,0 +1,68 @@ +"""Config flow for Ruckus Unleashed integration.""" +from pyruckus import Ruckus +from pyruckus.exceptions import AuthenticationError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME + +from .const import _LOGGER, DOMAIN # pylint:disable=unused-import + +DATA_SCHEMA = vol.Schema({"host": str, "username": str, "password": str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + try: + ruckus = await hass.async_add_executor_job( + Ruckus, data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] + ) + except AuthenticationError as error: + raise InvalidAuth from error + except ConnectionError as error: + raise CannotConnect from error + + mesh_name = ruckus.mesh_name() + + return {"title": mesh_name} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ruckus Unleashed.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_HOST]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/ruckus_unleashed/const.py b/homeassistant/components/ruckus_unleashed/const.py new file mode 100644 index 00000000000..04ea864a3c3 --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/const.py @@ -0,0 +1,13 @@ +"""Constants for the Ruckus Unleashed integration.""" +import logging + +DOMAIN = "ruckus_unleashed" +PLATFORMS = ["device_tracker"] +SCAN_INTERVAL = 180 + +_LOGGER = logging.getLogger(__name__) + +COORDINATOR = "coordinator" +UNDO_UPDATE_LISTENERS = "undo_update_listeners" + +CLIENTS = "clients" diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py new file mode 100644 index 00000000000..b57243c5f17 --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -0,0 +1,36 @@ +"""Ruckus Unleashed DataUpdateCoordinator.""" +from datetime import timedelta + +from pyruckus import Ruckus +from pyruckus.exceptions import AuthenticationError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import _LOGGER, CLIENTS, DOMAIN, SCAN_INTERVAL + + +class RuckusUnleashedDataUpdateCoordinator(DataUpdateCoordinator): + """Coordinator to manage data from Ruckus Unleashed client.""" + + def __init__(self, hass: HomeAssistant, *, ruckus: Ruckus): + """Initialize global Ruckus Unleashed data updater.""" + self.ruckus = ruckus + + update_interval = timedelta(seconds=SCAN_INTERVAL) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=update_interval, + ) + + async def _async_update_data(self) -> dict: + """Fetch Ruckus Unleashed data.""" + try: + return { + CLIENTS: await self.hass.async_add_executor_job(self.ruckus.clients) + } + except (AuthenticationError, ConnectionError) as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py new file mode 100644 index 00000000000..b64aa960727 --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -0,0 +1,117 @@ +"""Support for Ruckus Unleashed devices.""" +from homeassistant.components.device_tracker import ( + ATTR_MAC, + ATTR_SOURCE_TYPE, + SOURCE_TYPE_ROUTER, +) +from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import callback +from homeassistant.helpers import entity_registry +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CLIENTS, COORDINATOR, DOMAIN, UNDO_UPDATE_LISTENERS + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up device tracker for Ruckus Unleashed component.""" + coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR] + + tracked = set() + + @callback + def router_update(): + """Update the values of the router.""" + add_new_entities(coordinator, async_add_entities, tracked) + + router_update() + + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS].append( + coordinator.async_add_listener(router_update) + ) + + registry = await entity_registry.async_get_registry(hass) + restore_entities(registry, coordinator, entry, async_add_entities, tracked) + + +@callback +def add_new_entities(coordinator, async_add_entities, tracked): + """Add new tracker entities from the router.""" + new_tracked = [] + + for mac in coordinator.data[CLIENTS].keys(): + if mac in tracked: + continue + + device = coordinator.data[CLIENTS][mac] + new_tracked.append(RuckusUnleashedDevice(coordinator, mac, device[CONF_NAME])) + tracked.add(mac) + + if new_tracked: + async_add_entities(new_tracked, True) + + +@callback +def restore_entities(registry, coordinator, entry, async_add_entities, tracked): + """Restore clients that are not a part of active clients list.""" + missing = [] + + for entity in registry.entities.values(): + if entity.config_entry_id == entry.entry_id and entity.platform == DOMAIN: + if entity.unique_id not in coordinator.data[CLIENTS]: + missing.append( + RuckusUnleashedDevice( + coordinator, entity.unique_id, entity.original_name + ) + ) + tracked.add(entity.unique_id) + + if missing: + async_add_entities(missing, True) + + +class RuckusUnleashedDevice(CoordinatorEntity, ScannerEntity): + """Representation of a Ruckus Unleashed client.""" + + def __init__(self, coordinator, mac, name) -> None: + """Initialize a Ruckus Unleashed client.""" + super().__init__(coordinator) + self._mac = mac + self._name = name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._mac + + @property + def name(self) -> str: + """Return the name.""" + if self.is_connected: + return ( + self.coordinator.data[CLIENTS][self._mac][CONF_NAME] + or f"Ruckus {self._mac}" + ) + return self._name + + @property + def is_connected(self) -> bool: + """Return true if the device is connected to the network.""" + return self._mac in self.coordinator.data[CLIENTS] + + @property + def source_type(self) -> str: + """Return the source type.""" + return SOURCE_TYPE_ROUTER + + @property + def state_attributes(self) -> dict: + """Return the state attributes.""" + return { + ATTR_SOURCE_TYPE: self.source_type, + ATTR_MAC: self._mac, + } diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json new file mode 100644 index 00000000000..c9b9b6fed62 --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "ruckus_unleashed", + "name": "Ruckus Unleashed", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ruckus_unleashed", + "requirements": [ + "pyruckus==0.7" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@gabe565" + ] +} \ No newline at end of file diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json new file mode 100644 index 00000000000..82db44bbd18 --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -0,0 +1,22 @@ +{ + "title": "Ruckus Unleashed", + "config": { + "step": { + "user": { + "data": { + "host": "Host", + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ruckus_unleashed/translations/en.json b/homeassistant/components/ruckus_unleashed/translations/en.json new file mode 100644 index 00000000000..b916b420aec --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "username": "Username" + } + } + } + }, + "title": "Ruckus Unleashed" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5ac224eba2f..6266660546f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -158,6 +158,7 @@ FLOWS = [ "roomba", "roon", "rpi_power", + "ruckus_unleashed", "samsungtv", "sense", "sentry", diff --git a/requirements_all.txt b/requirements_all.txt index e17aa1cb7ac..d923ab7ace4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1626,6 +1626,9 @@ pyrepetier==3.0.5 # homeassistant.components.risco pyrisco==0.3.1 +# homeassistant.components.ruckus_unleashed +pyruckus==0.7 + # homeassistant.components.sabnzbd pysabnzbd==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e1e3855dcc..f915e76ff8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -794,6 +794,9 @@ pyqwikswitch==0.93 # homeassistant.components.risco pyrisco==0.3.1 +# homeassistant.components.ruckus_unleashed +pyruckus==0.7 + # homeassistant.components.acer_projector # homeassistant.components.zha pyserial==3.4 diff --git a/tests/components/ruckus_unleashed/__init__.py b/tests/components/ruckus_unleashed/__init__.py new file mode 100644 index 00000000000..5329d253aff --- /dev/null +++ b/tests/components/ruckus_unleashed/__init__.py @@ -0,0 +1,61 @@ +"""Tests for the Ruckus Unleashed integration.""" +from homeassistant.components.ruckus_unleashed import DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_NAME, + CONF_PASSWORD, + CONF_USERNAME, +) + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +DEFAULT_TITLE = "Ruckus Mesh" + +CONFIG = { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", +} + +TEST_CLIENT_ENTITY_ID = "device_tracker.ruckus_test_device" +TEST_CLIENT = { + CONF_IP_ADDRESS: "1.1.1.2", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "Ruckus Test Device", +} + + +def mock_config_entry() -> MockConfigEntry: + """Return a Ruckus Unleashed mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_TITLE, + unique_id="1.1.1.1", + data=CONFIG, + options=None, + ) + + +async def init_integration(hass) -> MockConfigEntry: + """Set up the Ruckus Unleashed integration in Home Assistant.""" + entry = mock_config_entry() + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.connect", + return_value=None, + ), patch( + "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", + return_value=DEFAULT_TITLE, + ), patch( + "homeassistant.components.ruckus_unleashed.Ruckus.clients", + return_value={ + TEST_CLIENT[CONF_MAC]: TEST_CLIENT, + }, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/ruckus_unleashed/test_config_flow.py b/tests/components/ruckus_unleashed/test_config_flow.py new file mode 100644 index 00000000000..3f6ce6119fc --- /dev/null +++ b/tests/components/ruckus_unleashed/test_config_flow.py @@ -0,0 +1,99 @@ +"""Test the Ruckus Unleashed config flow.""" +from pyruckus.exceptions import AuthenticationError + +from homeassistant import config_entries, setup +from homeassistant.components.ruckus_unleashed.const import DOMAIN + +from tests.async_mock import patch +from tests.components.ruckus_unleashed import CONFIG, DEFAULT_TITLE + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.connect", + return_value=None, + ), patch( + "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", + return_value=DEFAULT_TITLE, + ), patch( + "homeassistant.components.ruckus_unleashed.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.ruckus_unleashed.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == DEFAULT_TITLE + assert result2["data"] == CONFIG + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.connect", + side_effect=AuthenticationError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.connect", + side_effect=ConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.connect", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + CONFIG, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} diff --git a/tests/components/ruckus_unleashed/test_device_tracker.py b/tests/components/ruckus_unleashed/test_device_tracker.py new file mode 100644 index 00000000000..cd529ac6bad --- /dev/null +++ b/tests/components/ruckus_unleashed/test_device_tracker.py @@ -0,0 +1,103 @@ +"""The sensor tests for the Ruckus Unleashed platform.""" +from datetime import timedelta + +from homeassistant.components.ruckus_unleashed import DOMAIN +from homeassistant.const import CONF_MAC, STATE_HOME, STATE_NOT_HOME, STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry +from homeassistant.util import utcnow + +from tests.async_mock import patch +from tests.common import async_fire_time_changed +from tests.components.ruckus_unleashed import ( + DEFAULT_TITLE, + TEST_CLIENT, + TEST_CLIENT_ENTITY_ID, + init_integration, + mock_config_entry, +) + + +async def test_client_connected(hass): + """Test client connected.""" + await init_integration(hass) + + future = utcnow() + timedelta(minutes=60) + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.clients", + return_value={ + TEST_CLIENT[CONF_MAC]: TEST_CLIENT, + }, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + await hass.helpers.entity_component.async_update_entity(TEST_CLIENT_ENTITY_ID) + test_client = hass.states.get(TEST_CLIENT_ENTITY_ID) + assert test_client.state == STATE_HOME + + +async def test_client_disconnected(hass): + """Test client disconnected.""" + await init_integration(hass) + + future = utcnow() + timedelta(minutes=60) + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.clients", + return_value={}, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + await hass.helpers.entity_component.async_update_entity(TEST_CLIENT_ENTITY_ID) + test_client = hass.states.get(TEST_CLIENT_ENTITY_ID) + assert test_client.state == STATE_NOT_HOME + + +async def test_clients_update_failed(hass): + """Test failed update.""" + await init_integration(hass) + + future = utcnow() + timedelta(minutes=60) + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.clients", + side_effect=ConnectionError, + ): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + await hass.helpers.entity_component.async_update_entity(TEST_CLIENT_ENTITY_ID) + test_client = hass.states.get(TEST_CLIENT_ENTITY_ID) + assert test_client.state == STATE_UNAVAILABLE + + +async def test_restoring_clients(hass): + """Test restoring existing device_tracker entities if not detected on startup.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + registry = await entity_registry.async_get_registry(hass) + registry.async_get_or_create( + "device_tracker", + DOMAIN, + TEST_CLIENT[CONF_MAC], + suggested_object_id="ruckus_test_device", + config_entry=entry, + ) + + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus.connect", + return_value=None, + ), patch( + "homeassistant.components.ruckus_unleashed.Ruckus.mesh_name", + return_value=DEFAULT_TITLE, + ), patch( + "homeassistant.components.ruckus_unleashed.Ruckus.clients", + return_value={}, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + device = hass.states.get(TEST_CLIENT_ENTITY_ID) + assert device is not None + assert device.state == STATE_NOT_HOME diff --git a/tests/components/ruckus_unleashed/test_init.py b/tests/components/ruckus_unleashed/test_init.py new file mode 100644 index 00000000000..3c12e4f9665 --- /dev/null +++ b/tests/components/ruckus_unleashed/test_init.py @@ -0,0 +1,54 @@ +"""Test the Ruckus Unleashed config flow.""" +from pyruckus.exceptions import AuthenticationError + +from homeassistant.components.ruckus_unleashed import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) + +from tests.async_mock import patch +from tests.components.ruckus_unleashed import init_integration, mock_config_entry + + +async def test_setup_entry_login_error(hass): + """Test entry setup failed due to login error.""" + entry = mock_config_entry() + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus", + side_effect=AuthenticationError, + ): + entry.add_to_hass(hass) + result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert result is False + + +async def test_setup_entry_connection_error(hass): + """Test entry setup failed due to connection error.""" + entry = mock_config_entry() + with patch( + "homeassistant.components.ruckus_unleashed.Ruckus", + side_effect=ConnectionError, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_entry(hass): + """Test successful unload of entry.""" + entry = await init_integration(hass) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_NOT_LOADED + assert not hass.data.get(DOMAIN)