diff --git a/.coveragerc b/.coveragerc index 92caff7127b501..c49073f28f0346 100644 --- a/.coveragerc +++ b/.coveragerc @@ -10,6 +10,14 @@ omit = # omit pieces of code that rely on external devices being present homeassistant/components/acer_projector/switch.py homeassistant/components/actiontec/device_tracker.py + homeassistant/components/acmeda/__init__.py + homeassistant/components/acmeda/base.py + homeassistant/components/acmeda/const.py + homeassistant/components/acmeda/cover.py + homeassistant/components/acmeda/errors.py + homeassistant/components/acmeda/helpers.py + homeassistant/components/acmeda/hub.py + homeassistant/components/acmeda/sensor.py homeassistant/components/adguard/__init__.py homeassistant/components/adguard/const.py homeassistant/components/adguard/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 82c9c05bbb5be5..ff3ae7b8bb4535 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -14,6 +14,7 @@ homeassistant/scripts/check_config.py @kellerza # Integrations homeassistant/components/abode/* @shred86 +homeassistant/components/acmeda/* @atmurray homeassistant/components/adguard/* @frenck homeassistant/components/agent_dvr/* @ispysoftware homeassistant/components/airly/* @bieniu diff --git a/homeassistant/components/acmeda/__init__.py b/homeassistant/components/acmeda/__init__.py new file mode 100644 index 00000000000000..3b4f135a6fd937 --- /dev/null +++ b/homeassistant/components/acmeda/__init__.py @@ -0,0 +1,59 @@ +"""The Rollease Acmeda Automate integration.""" +import asyncio + +from homeassistant import config_entries, core + +from .const import DOMAIN +from .hub import PulseHub + +CONF_HUBS = "hubs" + +PLATFORMS = ["cover", "sensor"] + + +async def async_setup(hass: core.HomeAssistant, config: dict): + """Set up the Rollease Acmeda Automate component.""" + return True + + +async def async_setup_entry( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +): + """Set up Rollease Acmeda Automate hub from a config entry.""" + hub = PulseHub(hass, config_entry) + + if not await hub.async_setup(): + return False + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = hub + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_unload_entry( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +): + """Unload a config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, component) + for component in PLATFORMS + ] + ) + ) + if not await hub.async_reset(): + return False + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/acmeda/base.py b/homeassistant/components/acmeda/base.py new file mode 100644 index 00000000000000..c467fe17ba30c7 --- /dev/null +++ b/homeassistant/components/acmeda/base.py @@ -0,0 +1,89 @@ +"""Base class for Acmeda Roller Blinds.""" +import aiopulse + +from homeassistant.core import callback +from homeassistant.helpers import entity +from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg + +from .const import ACMEDA_ENTITY_REMOVE, DOMAIN, LOGGER + + +class AcmedaBase(entity.Entity): + """Base representation of an Acmeda roller.""" + + def __init__(self, roller: aiopulse.Roller): + """Initialize the roller.""" + self.roller = roller + + async def async_remove_and_unregister(self): + """Unregister from entity and device registry and call entity remove function.""" + LOGGER.error("Removing %s %s", self.__class__.__name__, self.unique_id) + + ent_registry = await get_ent_reg(self.hass) + if self.entity_id in ent_registry.entities: + ent_registry.async_remove(self.entity_id) + + dev_registry = await get_dev_reg(self.hass) + device = dev_registry.async_get_device( + identifiers={(DOMAIN, self.unique_id)}, connections=set() + ) + if device is not None: + dev_registry.async_update_device( + device.id, remove_config_entry_id=self.registry_entry.config_entry_id + ) + + await self.async_remove() + + async def async_added_to_hass(self): + """Entity has been added to hass.""" + self.roller.callback_subscribe(self.notify_update) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, + ACMEDA_ENTITY_REMOVE.format(self.roller.id), + self.async_remove_and_unregister, + ) + ) + + async def async_will_remove_from_hass(self): + """Entity being removed from hass.""" + self.roller.callback_unsubscribe(self.notify_update) + + @callback + def notify_update(self): + """Write updated device state information.""" + LOGGER.debug("Device update notification received: %s", self.name) + self.async_write_ha_state() + + @property + def should_poll(self): + """Report that Acmeda entities do not need polling.""" + return False + + @property + def unique_id(self): + """Return the unique ID of this roller.""" + return self.roller.id + + @property + def device_id(self): + """Return the ID of this roller.""" + return self.roller.id + + @property + def name(self): + """Return the name of roller.""" + return self.roller.name + + @property + def device_info(self): + """Return the device info.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.roller.name, + "manufacturer": "Rollease Acmeda", + "via_device": (DOMAIN, self.roller.hub.id), + } diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py new file mode 100644 index 00000000000000..33dac4814e54ff --- /dev/null +++ b/homeassistant/components/acmeda/config_flow.py @@ -0,0 +1,71 @@ +"""Config flow for Rollease Acmeda Automate Pulse Hub.""" +import asyncio +from typing import Dict, Optional + +import aiopulse +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries + +from .const import DOMAIN # pylint: disable=unused-import + + +class AcmedaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Acmeda config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the config flow.""" + self.discovered_hubs: Optional[Dict[str, aiopulse.Hub]] = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if ( + user_input is not None + and self.discovered_hubs is not None + # pylint: disable=unsupported-membership-test + and user_input["id"] in self.discovered_hubs + ): + # pylint: disable=unsubscriptable-object + return await self.async_create(self.discovered_hubs[user_input["id"]]) + + # Already configured hosts + already_configured = { + entry.unique_id for entry in self._async_current_entries() + } + + hubs = [] + try: + with async_timeout.timeout(5): + async for hub in aiopulse.Hub.discover(): + if hub.id not in already_configured: + hubs.append(hub) + except asyncio.TimeoutError: + pass + + if len(hubs) == 0: + return self.async_abort(reason="all_configured") + + if len(hubs) == 1: + return await self.async_create(hubs[0]) + + self.discovered_hubs = {hub.id: hub for hub in hubs} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("id"): vol.In( + {hub.id: f"{hub.id} {hub.host}" for hub in hubs} + ) + } + ), + ) + + async def async_create(self, hub): + """Create the Acmeda Hub entry.""" + await self.async_set_unique_id(hub.id, raise_on_progress=False) + return self.async_create_entry(title=hub.id, data={"host": hub.host}) diff --git a/homeassistant/components/acmeda/const.py b/homeassistant/components/acmeda/const.py new file mode 100644 index 00000000000000..b8712fee4ba542 --- /dev/null +++ b/homeassistant/components/acmeda/const.py @@ -0,0 +1,8 @@ +"""Constants for the Rollease Acmeda Automate integration.""" +import logging + +LOGGER = logging.getLogger(__package__) +DOMAIN = "acmeda" + +ACMEDA_HUB_UPDATE = "acmeda_hub_update_{}" +ACMEDA_ENTITY_REMOVE = "acmeda_entity_remove_{}" diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py new file mode 100644 index 00000000000000..0a4436bc073570 --- /dev/null +++ b/homeassistant/components/acmeda/cover.py @@ -0,0 +1,122 @@ +"""Support for Acmeda Roller Blinds.""" +from homeassistant.components.cover import ( + ATTR_POSITION, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, + SUPPORT_STOP_TILT, + CoverEntity, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .base import AcmedaBase +from .const import ACMEDA_HUB_UPDATE, DOMAIN +from .helpers import async_add_acmeda_entities + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Acmeda Rollers from a config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + current = set() + + @callback + def async_add_acmeda_covers(): + async_add_acmeda_entities( + hass, AcmedaCover, config_entry, current, async_add_entities + ) + + hub.cleanup_callbacks.append( + async_dispatcher_connect( + hass, + ACMEDA_HUB_UPDATE.format(config_entry.entry_id), + async_add_acmeda_covers, + ) + ) + + +class AcmedaCover(AcmedaBase, CoverEntity): + """Representation of a Acmeda cover device.""" + + @property + def current_cover_position(self): + """Return the current position of the roller blind. + + None is unknown, 0 is closed, 100 is fully open. + """ + position = None + if self.roller.type != 7: + position = 100 - self.roller.closed_percent + return position + + @property + def current_cover_tilt_position(self): + """Return the current tilt of the roller blind. + + None is unknown, 0 is closed, 100 is fully open. + """ + position = None + if self.roller.type == 7 or self.roller.type == 10: + position = 100 - self.roller.closed_percent + return position + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = 0 + if self.current_cover_position is not None: + supported_features |= ( + SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP | SUPPORT_SET_POSITION + ) + if self.current_cover_tilt_position is not None: + supported_features |= ( + SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + | SUPPORT_SET_TILT_POSITION + ) + + return supported_features + + @property + def is_closed(self): + """Return if the cover is closed.""" + is_closed = self.roller.closed_percent == 100 + return is_closed + + async def close_cover(self, **kwargs): + """Close the roller.""" + await self.roller.move_down() + + async def open_cover(self, **kwargs): + """Open the roller.""" + await self.roller.move_up() + + async def stop_cover(self, **kwargs): + """Stop the roller.""" + await self.roller.move_stop() + + async def set_cover_position(self, **kwargs): + """Move the roller shutter to a specific position.""" + await self.roller.move_to(100 - kwargs[ATTR_POSITION]) + + async def close_cover_tilt(self, **kwargs): + """Close the roller.""" + await self.roller.move_down() + + async def open_cover_tilt(self, **kwargs): + """Open the roller.""" + await self.roller.move_up() + + async def stop_cover_tilt(self, **kwargs): + """Stop the roller.""" + await self.roller.move_stop() + + async def set_cover_tilt(self, **kwargs): + """Tilt the roller shutter to a specific position.""" + await self.roller.move_to(100 - kwargs[ATTR_POSITION]) diff --git a/homeassistant/components/acmeda/errors.py b/homeassistant/components/acmeda/errors.py new file mode 100644 index 00000000000000..f26090df03d71f --- /dev/null +++ b/homeassistant/components/acmeda/errors.py @@ -0,0 +1,10 @@ +"""Errors for the Acmeda Pulse component.""" +from homeassistant.exceptions import HomeAssistantError + + +class PulseException(HomeAssistantError): + """Base class for Acmeda Pulse exceptions.""" + + +class CannotConnect(PulseException): + """Unable to connect to the bridge.""" diff --git a/homeassistant/components/acmeda/helpers.py b/homeassistant/components/acmeda/helpers.py new file mode 100644 index 00000000000000..f8ea744be77f49 --- /dev/null +++ b/homeassistant/components/acmeda/helpers.py @@ -0,0 +1,41 @@ +"""Helper functions for Acmeda Pulse.""" +from homeassistant.core import callback +from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg + +from .const import DOMAIN, LOGGER + + +@callback +def async_add_acmeda_entities( + hass, entity_class, config_entry, current, async_add_entities +): + """Add any new entities.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + LOGGER.debug("Looking for new %s on: %s", entity_class.__name__, hub.host) + + api = hub.api.rollers + + new_items = [] + for unique_id, roller in api.items(): + if unique_id not in current: + LOGGER.debug("New %s %s", entity_class.__name__, unique_id) + new_item = entity_class(roller) + current.add(unique_id) + new_items.append(new_item) + + async_add_entities(new_items) + + +async def update_devices(hass, config_entry, api): + """Tell hass that device info has been updated.""" + dev_registry = await get_dev_reg(hass) + + for api_item in api.values(): + # Update Device name + device = dev_registry.async_get_device( + identifiers={(DOMAIN, api_item.id)}, connections=set() + ) + if device is not None: + dev_registry.async_update_device( + device.id, name=api_item.name, + ) diff --git a/homeassistant/components/acmeda/hub.py b/homeassistant/components/acmeda/hub.py new file mode 100644 index 00000000000000..0b74b874dcc838 --- /dev/null +++ b/homeassistant/components/acmeda/hub.py @@ -0,0 +1,88 @@ +"""Code to handle a Pulse Hub.""" +import asyncio +from typing import Optional + +import aiopulse + +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ACMEDA_ENTITY_REMOVE, ACMEDA_HUB_UPDATE, LOGGER +from .helpers import update_devices + + +class PulseHub: + """Manages a single Pulse Hub.""" + + def __init__(self, hass, config_entry): + """Initialize the system.""" + self.config_entry = config_entry + self.hass = hass + self.api: Optional[aiopulse.Hub] = None + self.tasks = [] + self.current_rollers = {} + self.cleanup_callbacks = [] + + @property + def title(self): + """Return the title of the hub shown in the integrations list.""" + return f"{self.api.id} ({self.api.host})" + + @property + def host(self): + """Return the host of this hub.""" + return self.config_entry.data["host"] + + async def async_setup(self, tries=0): + """Set up a hub based on host parameter.""" + host = self.host + + hub = aiopulse.Hub(host) + self.api = hub + + hub.callback_subscribe(self.async_notify_update) + self.tasks.append(asyncio.create_task(hub.run())) + + LOGGER.debug("Hub setup complete") + return True + + async def async_reset(self): + """Reset this hub to default state.""" + + for cleanup_callback in self.cleanup_callbacks: + cleanup_callback() + + # If not setup + if self.api is None: + return False + + self.api.callback_unsubscribe(self.async_notify_update) + await self.api.stop() + del self.api + self.api = None + + # Wait for any running tasks to complete + await asyncio.wait(self.tasks) + + return True + + async def async_notify_update(self, update_type): + """Evaluate entities when hub reports that update has occurred.""" + LOGGER.debug("Hub {update_type.name} updated") + + if update_type == aiopulse.UpdateType.rollers: + await update_devices(self.hass, self.config_entry, self.api.rollers) + self.hass.config_entries.async_update_entry( + self.config_entry, title=self.title + ) + + async_dispatcher_send( + self.hass, ACMEDA_HUB_UPDATE.format(self.config_entry.entry_id) + ) + + for unique_id in list(self.current_rollers): + if unique_id not in self.api.rollers: + LOGGER.debug("Notifying remove of %s", unique_id) + self.current_rollers.pop(unique_id) + async_dispatcher_send( + self.hass, ACMEDA_ENTITY_REMOVE.format(unique_id) + ) diff --git a/homeassistant/components/acmeda/manifest.json b/homeassistant/components/acmeda/manifest.json new file mode 100644 index 00000000000000..8b76af0c57eb87 --- /dev/null +++ b/homeassistant/components/acmeda/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "acmeda", + "name": "Rollease Acmeda Automate", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/acmeda", + "requirements": ["aiopulse==0.4.0"], + "codeowners": [ + "@atmurray" + ] +} \ No newline at end of file diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py new file mode 100644 index 00000000000000..e549160fbdda7b --- /dev/null +++ b/homeassistant/components/acmeda/sensor.py @@ -0,0 +1,46 @@ +"""Support for Acmeda Roller Blind Batteries.""" +from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .base import AcmedaBase +from .const import ACMEDA_HUB_UPDATE, DOMAIN +from .helpers import async_add_acmeda_entities + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the Acmeda Rollers from a config entry.""" + hub = hass.data[DOMAIN][config_entry.entry_id] + + current = set() + + @callback + def async_add_acmeda_sensors(): + async_add_acmeda_entities( + hass, AcmedaBattery, config_entry, current, async_add_entities + ) + + hub.cleanup_callbacks.append( + async_dispatcher_connect( + hass, + ACMEDA_HUB_UPDATE.format(config_entry.entry_id), + async_add_acmeda_sensors, + ) + ) + + +class AcmedaBattery(AcmedaBase): + """Representation of a Acmeda cover device.""" + + device_class = DEVICE_CLASS_BATTERY + unit_of_measurement = UNIT_PERCENTAGE + + @property + def name(self): + """Return the name of roller.""" + return f"{super().name} Battery" + + @property + def state(self): + """Return the state of the device.""" + return self.roller.battery diff --git a/homeassistant/components/acmeda/strings.json b/homeassistant/components/acmeda/strings.json new file mode 100644 index 00000000000000..eb7ed44999b2db --- /dev/null +++ b/homeassistant/components/acmeda/strings.json @@ -0,0 +1,16 @@ +{ + "title": "Rollease Acmeda Automate", + "config": { + "step": { + "user": { + "title": "Pick a hub to add", + "data": { + "id": "Host ID" + } + } + }, + "abort": { + "all_configured": "No new Pulse hubs discovered." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/acmeda/translations/en.json b/homeassistant/components/acmeda/translations/en.json new file mode 100644 index 00000000000000..eb7ed44999b2db --- /dev/null +++ b/homeassistant/components/acmeda/translations/en.json @@ -0,0 +1,16 @@ +{ + "title": "Rollease Acmeda Automate", + "config": { + "step": { + "user": { + "title": "Pick a hub to add", + "data": { + "id": "Host ID" + } + } + }, + "abort": { + "all_configured": "No new Pulse hubs discovered." + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e1159064bf8225..10d6e11baf43bb 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -7,6 +7,7 @@ FLOWS = [ "abode", + "acmeda", "adguard", "agent_dvr", "airly", diff --git a/requirements_all.txt b/requirements_all.txt index ad64d45be8424f..264750a098908b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -205,6 +205,9 @@ aionotify==0.2.0 # homeassistant.components.notion aionotion==1.1.0 +# homeassistant.components.acmeda +aiopulse==0.4.0 + # homeassistant.components.hunterdouglas_powerview aiopvapi==1.6.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9fbb1f0d8cdb22..66676368988ef2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,6 +94,9 @@ aiohue==2.1.0 # homeassistant.components.notion aionotion==1.1.0 +# homeassistant.components.acmeda +aiopulse==0.4.0 + # homeassistant.components.hunterdouglas_powerview aiopvapi==1.6.14 diff --git a/tests/components/acmeda/__init__.py b/tests/components/acmeda/__init__.py new file mode 100644 index 00000000000000..126c834d1ee1f1 --- /dev/null +++ b/tests/components/acmeda/__init__.py @@ -0,0 +1 @@ +"""Tests for the Rollease Acmeda Automate integration.""" diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py new file mode 100644 index 00000000000000..1663bc3d443df7 --- /dev/null +++ b/tests/components/acmeda/test_config_flow.py @@ -0,0 +1,143 @@ +"""Define tests for the Acmeda config flow.""" +import aiopulse +from asynctest.mock import patch +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.acmeda.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + +DUMMY_HOST1 = "127.0.0.1" +DUMMY_HOST2 = "127.0.0.2" + +CONFIG = { + CONF_HOST: DUMMY_HOST1, +} + + +@pytest.fixture +def mock_hub_discover(): + """Mock the hub discover method.""" + with patch("aiopulse.Hub.discover") as mock_discover: + yield mock_discover + + +@pytest.fixture +def mock_hub_run(): + """Mock the hub run method.""" + with patch("aiopulse.Hub.run") as mock_run: + yield mock_run + + +async def async_generator(items): + """Async yields items provided in a list.""" + for item in items: + yield item + + +async def test_show_form_no_hubs(hass, mock_hub_discover): + """Test that flow aborts if no hubs are discovered.""" + mock_hub_discover.return_value = async_generator([]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "all_configured" + + # Check we performed the discovery + assert len(mock_hub_discover.mock_calls) == 1 + + +async def test_show_form_one_hub(hass, mock_hub_discover, mock_hub_run): + """Test that a config is created when one hub discovered.""" + + dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) + dummy_hub_1.id = "ABC123" + + mock_hub_discover.return_value = async_generator([dummy_hub_1]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == dummy_hub_1.id + assert result["result"].data == { + "host": DUMMY_HOST1, + } + + # Check we performed the discovery + assert len(mock_hub_discover.mock_calls) == 1 + + +async def test_show_form_two_hubs(hass, mock_hub_discover): + """Test that the form is served when more than one hub discovered.""" + + dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) + dummy_hub_1.id = "ABC123" + + dummy_hub_2 = aiopulse.Hub(DUMMY_HOST1) + dummy_hub_2.id = "DEF456" + + mock_hub_discover.return_value = async_generator([dummy_hub_1, dummy_hub_2]) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # Check we performed the discovery + assert len(mock_hub_discover.mock_calls) == 1 + + +async def test_create_second_entry(hass, mock_hub_run, mock_hub_discover): + """Test that a config is created when a second hub is discovered.""" + + dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) + dummy_hub_1.id = "ABC123" + + dummy_hub_2 = aiopulse.Hub(DUMMY_HOST2) + dummy_hub_2.id = "DEF456" + + mock_hub_discover.return_value = async_generator([dummy_hub_1, dummy_hub_2]) + + MockConfigEntry(domain=DOMAIN, unique_id=dummy_hub_1.id, data=CONFIG).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == dummy_hub_2.id + assert result["result"].data == { + "host": DUMMY_HOST2, + } + + +async def test_already_configured(hass, mock_hub_discover): + """Test that flow aborts when all hubs are configured.""" + + dummy_hub_1 = aiopulse.Hub(DUMMY_HOST1) + dummy_hub_1.id = "ABC123" + + mock_hub_discover.return_value = async_generator([dummy_hub_1]) + + MockConfigEntry(domain=DOMAIN, unique_id=dummy_hub_1.id, data=CONFIG).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == "abort" + assert result["reason"] == "all_configured"