mirror of
https://github.com/home-assistant/core.git
synced 2025-06-25 01:21:51 +02:00
UniFi POE control (#17011)
* First commit * Feature complete? * Add dependency * Move setting poe mode logic to library * Use guard clauses * Bump requirement to 2 * Simplify saving switches with poe off * Store and use poe mode * Fix indentation * Fix flake8 * Configuration future proofing * Bump dependency to v3 * Add first test * Proper use of defaults with config flow (thanks helto) * Appease hound * Make sure there can't be duplicate entries of combination host+site * More tests * More tests * 98% coverage of controller * Fix hound comments * Config flow step init not necessary * Use async_current_entries to check if host and site for controller is used * Remove storing/restoring poe off devices to slim PR * First batch of switch tests * More switch tests. * Small improvements and clean up * Make tests pass Don't name device in device registry * Dont process clients that belong to non-UniFi POE switches * Allow selection of site from a list in config flow * Fix double blank lines in method * Update codeowners
This commit is contained in:
committed by
Paulus Schoutsen
parent
0c0c471447
commit
a795093705
@ -224,6 +224,8 @@ homeassistant/components/tradfri/* @ggravlingen
|
||||
homeassistant/components/*/tradfri.py @ggravlingen
|
||||
|
||||
# U
|
||||
homeassistant/components/unifi.py @kane610
|
||||
homeassistant/components/switch/unifi.py @kane610
|
||||
homeassistant/components/upcloud.py @scop
|
||||
homeassistant/components/*/upcloud.py @scop
|
||||
|
||||
|
230
homeassistant/components/switch/unifi.py
Normal file
230
homeassistant/components/switch/unifi.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""
|
||||
Support for devices connected to UniFi POE.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/switch.unifi/
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.components import unifi
|
||||
from homeassistant.components.switch import SwitchDevice
|
||||
from homeassistant.components.unifi.const import (
|
||||
CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID, DOMAIN)
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
|
||||
DEPENDENCIES = [DOMAIN]
|
||||
SCAN_INTERVAL = timedelta(seconds=15)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass, config, async_add_entities, discovery_info=None):
|
||||
"""Component doesn't support configuration through configuration.yaml."""
|
||||
pass
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||
"""Set up switches for UniFi component.
|
||||
|
||||
Switches are controlling network switch ports with Poe.
|
||||
"""
|
||||
controller_id = CONTROLLER_ID.format(
|
||||
host=config_entry.data[CONF_CONTROLLER][CONF_HOST],
|
||||
site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID]
|
||||
)
|
||||
controller = hass.data[unifi.DOMAIN][controller_id]
|
||||
switches = {}
|
||||
|
||||
progress = None
|
||||
update_progress = set()
|
||||
|
||||
async def request_update(object_id):
|
||||
"""Request an update."""
|
||||
nonlocal progress
|
||||
update_progress.add(object_id)
|
||||
|
||||
if progress is not None:
|
||||
return await progress
|
||||
|
||||
progress = asyncio.ensure_future(update_controller())
|
||||
result = await progress
|
||||
progress = None
|
||||
update_progress.clear()
|
||||
return result
|
||||
|
||||
async def update_controller():
|
||||
"""Update the values of the controller."""
|
||||
tasks = [async_update_items(
|
||||
controller, async_add_entities, request_update,
|
||||
switches, update_progress
|
||||
)]
|
||||
await asyncio.wait(tasks)
|
||||
|
||||
await update_controller()
|
||||
|
||||
|
||||
async def async_update_items(controller, async_add_entities,
|
||||
request_controller_update, switches,
|
||||
progress_waiting):
|
||||
"""Update POE port state from the controller."""
|
||||
import aiounifi
|
||||
|
||||
@callback
|
||||
def update_switch_state():
|
||||
"""Tell switches to reload state."""
|
||||
for client_id, client in switches.items():
|
||||
if client_id not in progress_waiting:
|
||||
client.async_schedule_update_ha_state()
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(4):
|
||||
await controller.api.clients.update()
|
||||
await controller.api.devices.update()
|
||||
|
||||
except aiounifi.LoginRequired:
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
await controller.api.login()
|
||||
except (asyncio.TimeoutError, aiounifi.AiounifiException):
|
||||
if controller.available:
|
||||
controller.available = False
|
||||
update_switch_state()
|
||||
return
|
||||
|
||||
except (asyncio.TimeoutError, aiounifi.AiounifiException):
|
||||
if controller.available:
|
||||
LOGGER.error('Unable to reach controller %s', controller.host)
|
||||
controller.available = False
|
||||
update_switch_state()
|
||||
return
|
||||
|
||||
if not controller.available:
|
||||
LOGGER.info('Reconnected to controller %s', controller.host)
|
||||
controller.available = True
|
||||
|
||||
new_switches = []
|
||||
devices = controller.api.devices
|
||||
for client_id in controller.api.clients:
|
||||
|
||||
if client_id in progress_waiting:
|
||||
continue
|
||||
|
||||
if client_id in switches:
|
||||
LOGGER.debug("Updating UniFi switch %s (%s)",
|
||||
switches[client_id].entity_id,
|
||||
switches[client_id].client.mac)
|
||||
switches[client_id].async_schedule_update_ha_state()
|
||||
continue
|
||||
|
||||
client = controller.api.clients[client_id]
|
||||
# Network device with active POE
|
||||
if not client.is_wired or client.sw_mac not in devices or \
|
||||
not devices[client.sw_mac].ports[client.sw_port].port_poe or \
|
||||
not devices[client.sw_mac].ports[client.sw_port].poe_enable:
|
||||
continue
|
||||
|
||||
# Multiple POE-devices on same port means non UniFi POE driven switch
|
||||
multi_clients_on_port = False
|
||||
for client2 in controller.api.clients.values():
|
||||
if client.mac != client2.mac and \
|
||||
client.sw_mac == client2.sw_mac and \
|
||||
client.sw_port == client2.sw_port:
|
||||
multi_clients_on_port = True
|
||||
break
|
||||
|
||||
if multi_clients_on_port:
|
||||
continue
|
||||
|
||||
switches[client_id] = UniFiSwitch(
|
||||
client, controller, request_controller_update)
|
||||
new_switches.append(switches[client_id])
|
||||
LOGGER.debug("New UniFi switch %s (%s)", client.hostname, client.mac)
|
||||
|
||||
if new_switches:
|
||||
async_add_entities(new_switches)
|
||||
|
||||
|
||||
class UniFiSwitch(SwitchDevice):
|
||||
"""Representation of a client that uses POE."""
|
||||
|
||||
def __init__(self, client, controller, request_controller_update):
|
||||
"""Set up switch."""
|
||||
self.client = client
|
||||
self.controller = controller
|
||||
self.poe_mode = None
|
||||
if self.port.poe_mode != 'off':
|
||||
self.poe_mode = self.port.poe_mode
|
||||
self.async_request_controller_update = request_controller_update
|
||||
|
||||
async def async_update(self):
|
||||
"""Synchronize state with controller."""
|
||||
await self.async_request_controller_update(self.client.mac)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the switch."""
|
||||
return self.client.hostname
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return a unique identifier for this switch."""
|
||||
return 'poe-{}'.format(self.client.mac)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if POE is active."""
|
||||
return self.port.poe_mode != 'off'
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return if switch is available."""
|
||||
return self.controller.available or \
|
||||
self.client.sw_mac in self.controller.api.devices
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Enable POE for client."""
|
||||
await self.device.async_set_port_poe_mode(
|
||||
self.client.sw_port, self.poe_mode)
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Disable POE for client."""
|
||||
await self.device.async_set_port_poe_mode(self.client.sw_port, 'off')
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device state attributes."""
|
||||
attributes = {
|
||||
'power': self.port.poe_power,
|
||||
'received': self.client.wired_rx_bytes / 1000000,
|
||||
'sent': self.client.wired_tx_bytes / 1000000,
|
||||
'switch': self.client.sw_mac,
|
||||
'port': self.client.sw_port,
|
||||
'poe_mode': self.poe_mode
|
||||
}
|
||||
return attributes
|
||||
|
||||
@property
|
||||
def device_info(self):
|
||||
"""Return a device description for device registry."""
|
||||
return {
|
||||
'connections': {(CONNECTION_NETWORK_MAC, self.client.mac)}
|
||||
}
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
"""Shortcut to the switch that client is connected to."""
|
||||
return self.controller.api.devices[self.client.sw_mac]
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
"""Shortcut to the switch port that client is connected to."""
|
||||
return self.device.ports[self.client.sw_port]
|
26
homeassistant/components/unifi/.translations/en.json
Normal file
26
homeassistant/components/unifi/.translations/en.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "UniFi Controller",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up UniFi Controller",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"username": "User name",
|
||||
"password": "Password",
|
||||
"port": "Port",
|
||||
"site": "Site ID",
|
||||
"verify_ssl": "Controller using proper certificate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"faulty_credentials": "Bad user credentials",
|
||||
"service_unavailable": "No service available"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Controller site is already configured",
|
||||
"user_privilege": "User needs to be administrator"
|
||||
}
|
||||
}
|
||||
}
|
186
homeassistant/components/unifi/__init__.py
Normal file
186
homeassistant/components/unifi/__init__.py
Normal file
@ -0,0 +1,186 @@
|
||||
"""
|
||||
Support for devices connected to UniFi POE.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/unifi/
|
||||
"""
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
|
||||
from .const import (CONF_CONTROLLER, CONF_POE_CONTROL, CONF_SITE_ID,
|
||||
CONTROLLER_ID, DOMAIN, LOGGER)
|
||||
from .controller import UniFiController, get_controller
|
||||
from .errors import (
|
||||
AlreadyConfigured, AuthenticationRequired, CannotConnect, UserLevel)
|
||||
|
||||
DEFAULT_PORT = 8443
|
||||
DEFAULT_SITE_ID = 'default'
|
||||
DEFAULT_VERIFY_SSL = False
|
||||
|
||||
REQUIREMENTS = ['aiounifi==3']
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Component doesn't support configuration through configuration.yaml."""
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass, config_entry):
|
||||
"""Set up the UniFi component."""
|
||||
controller = UniFiController(hass, config_entry)
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
controller_id = CONTROLLER_ID.format(
|
||||
host=config_entry.data[CONF_CONTROLLER][CONF_HOST],
|
||||
site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID]
|
||||
)
|
||||
|
||||
if not await controller.async_setup():
|
||||
return False
|
||||
|
||||
hass.data[DOMAIN][controller_id] = controller
|
||||
|
||||
if controller.mac is None:
|
||||
return True
|
||||
|
||||
device_registry = await \
|
||||
hass.helpers.device_registry.async_get_registry()
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
connections={(CONNECTION_NETWORK_MAC, controller.mac)},
|
||||
manufacturer='Ubiquiti',
|
||||
model="UniFi Controller",
|
||||
name="UniFi Controller",
|
||||
# sw_version=config.raw['swversion'],
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass, config_entry):
|
||||
"""Unload a config entry."""
|
||||
controller_id = CONTROLLER_ID.format(
|
||||
host=config_entry.data[CONF_CONTROLLER][CONF_HOST],
|
||||
site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID]
|
||||
)
|
||||
controller = hass.data[DOMAIN].pop(controller_id)
|
||||
return await controller.async_reset()
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class UnifiFlowHandler(config_entries.ConfigFlow):
|
||||
"""Handle a UniFi config flow."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the UniFi flow."""
|
||||
self.config = None
|
||||
self.desc = None
|
||||
self.sites = None
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
|
||||
try:
|
||||
self.config = {
|
||||
CONF_HOST: user_input[CONF_HOST],
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_PORT: user_input.get(CONF_PORT),
|
||||
CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL),
|
||||
CONF_SITE_ID: DEFAULT_SITE_ID,
|
||||
}
|
||||
controller = await get_controller(self.hass, **self.config)
|
||||
|
||||
self.sites = await controller.sites()
|
||||
|
||||
return await self.async_step_site()
|
||||
|
||||
except AuthenticationRequired:
|
||||
errors['base'] = 'faulty_credentials'
|
||||
|
||||
except CannotConnect:
|
||||
errors['base'] = 'service_unavailable'
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
LOGGER.error(
|
||||
'Unknown error connecting with UniFi Controller at %s',
|
||||
user_input[CONF_HOST])
|
||||
return self.async_abort(reason='unknown')
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='user',
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
|
||||
vol.Optional(
|
||||
CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_site(self, user_input=None):
|
||||
"""Select site to control."""
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
|
||||
try:
|
||||
desc = user_input.get(CONF_SITE_ID, self.desc)
|
||||
for site in self.sites.values():
|
||||
if desc == site['desc']:
|
||||
if site['role'] != 'admin':
|
||||
raise UserLevel
|
||||
self.config[CONF_SITE_ID] = site['name']
|
||||
break
|
||||
|
||||
for entry in self._async_current_entries():
|
||||
controller = entry.data[CONF_CONTROLLER]
|
||||
if controller[CONF_HOST] == self.config[CONF_HOST] and \
|
||||
controller[CONF_SITE_ID] == self.config[CONF_SITE_ID]:
|
||||
raise AlreadyConfigured
|
||||
|
||||
data = {
|
||||
CONF_CONTROLLER: self.config,
|
||||
CONF_POE_CONTROL: True
|
||||
}
|
||||
|
||||
return self.async_create_entry(
|
||||
title=desc,
|
||||
data=data
|
||||
)
|
||||
|
||||
except AlreadyConfigured:
|
||||
return self.async_abort(reason='already_configured')
|
||||
|
||||
except UserLevel:
|
||||
return self.async_abort(reason='user_privilege')
|
||||
|
||||
if len(self.sites) == 1:
|
||||
self.desc = next(iter(self.sites.values()))['desc']
|
||||
return await self.async_step_site(user_input={})
|
||||
|
||||
sites = []
|
||||
for site in self.sites.values():
|
||||
sites.append(site['desc'])
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='site',
|
||||
data_schema=vol.Schema({
|
||||
vol.Required(CONF_SITE_ID): vol.In(sites)
|
||||
}),
|
||||
errors=errors,
|
||||
)
|
12
homeassistant/components/unifi/const.py
Normal file
12
homeassistant/components/unifi/const.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Constants for the UniFi component."""
|
||||
|
||||
import logging
|
||||
|
||||
LOGGER = logging.getLogger('homeassistant.components.unifi')
|
||||
DOMAIN = 'unifi'
|
||||
|
||||
CONTROLLER_ID = '{host}-{site}'
|
||||
|
||||
CONF_CONTROLLER = 'controller'
|
||||
CONF_POE_CONTROL = 'poe_control'
|
||||
CONF_SITE_ID = 'site'
|
131
homeassistant/components/unifi/controller.py
Normal file
131
homeassistant/components/unifi/controller.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""UniFi Controller abstraction."""
|
||||
|
||||
import asyncio
|
||||
import async_timeout
|
||||
|
||||
from aiohttp import CookieJar
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
|
||||
from .const import CONF_CONTROLLER, CONF_POE_CONTROL, LOGGER
|
||||
from .errors import AuthenticationRequired, CannotConnect
|
||||
|
||||
|
||||
class UniFiController:
|
||||
"""Manages a single UniFi Controller."""
|
||||
|
||||
def __init__(self, hass, config_entry):
|
||||
"""Initialize the system."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.available = True
|
||||
self.api = None
|
||||
self.progress = None
|
||||
self._cancel_retry_setup = None
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""Return the host of this controller."""
|
||||
return self.config_entry.data[CONF_CONTROLLER][CONF_HOST]
|
||||
|
||||
@property
|
||||
def mac(self):
|
||||
"""Return the mac address of this controller."""
|
||||
for client in self.api.clients.values():
|
||||
if self.host == client.ip:
|
||||
return client.mac
|
||||
return None
|
||||
|
||||
async def async_setup(self, tries=0):
|
||||
"""Set up a UniFi controller."""
|
||||
hass = self.hass
|
||||
|
||||
try:
|
||||
self.api = await get_controller(
|
||||
self.hass, **self.config_entry.data[CONF_CONTROLLER])
|
||||
await self.api.initialize()
|
||||
|
||||
except CannotConnect:
|
||||
retry_delay = 2 ** (tries + 1)
|
||||
LOGGER.error("Error connecting to the UniFi controller. Retrying "
|
||||
"in %d seconds", retry_delay)
|
||||
|
||||
async def retry_setup(_now):
|
||||
"""Retry setup."""
|
||||
if await self.async_setup(tries + 1):
|
||||
# This feels hacky, we should find a better way to do this
|
||||
self.config_entry.state = config_entries.ENTRY_STATE_LOADED
|
||||
|
||||
self._cancel_retry_setup = hass.helpers.event.async_call_later(
|
||||
retry_delay, retry_setup)
|
||||
|
||||
return False
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
LOGGER.error(
|
||||
'Unknown error connecting with UniFi controller.')
|
||||
return False
|
||||
|
||||
if self.config_entry.data[CONF_POE_CONTROL]:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(
|
||||
self.config_entry, 'switch'))
|
||||
|
||||
return True
|
||||
|
||||
async def async_reset(self):
|
||||
"""Reset this controller to default state.
|
||||
|
||||
Will cancel any scheduled setup retry and will unload
|
||||
the config entry.
|
||||
"""
|
||||
# If we have a retry scheduled, we were never setup.
|
||||
if self._cancel_retry_setup is not None:
|
||||
self._cancel_retry_setup()
|
||||
self._cancel_retry_setup = None
|
||||
return True
|
||||
|
||||
# If the authentication was wrong.
|
||||
if self.api is None:
|
||||
return True
|
||||
|
||||
if self.config_entry.data[CONF_POE_CONTROL]:
|
||||
return await self.hass.config_entries.async_forward_entry_unload(
|
||||
self.config_entry, 'switch')
|
||||
return True
|
||||
|
||||
|
||||
async def get_controller(
|
||||
hass, host, username, password, port, site, verify_ssl):
|
||||
"""Create a controller object and verify authentication."""
|
||||
import aiounifi
|
||||
|
||||
if verify_ssl:
|
||||
session = aiohttp_client.async_get_clientsession(hass)
|
||||
else:
|
||||
session = aiohttp_client.async_create_clientsession(
|
||||
hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True))
|
||||
|
||||
controller = aiounifi.Controller(
|
||||
host, username=username, password=password, port=port, site=site,
|
||||
websession=session
|
||||
)
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
await controller.login()
|
||||
return controller
|
||||
|
||||
except aiounifi.Unauthorized:
|
||||
LOGGER.warning("Connected to UniFi at %s but not registered.", host)
|
||||
raise AuthenticationRequired
|
||||
|
||||
except (asyncio.TimeoutError, aiounifi.RequestError):
|
||||
LOGGER.error("Error connecting to the UniFi controller at %s", host)
|
||||
raise CannotConnect
|
||||
|
||||
except aiounifi.AiounifiException:
|
||||
LOGGER.exception('Unknown UniFi communication error occurred')
|
||||
raise AuthenticationRequired
|
26
homeassistant/components/unifi/errors.py
Normal file
26
homeassistant/components/unifi/errors.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Errors for the UniFi component."""
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
|
||||
class UnifiException(HomeAssistantError):
|
||||
"""Base class for UniFi exceptions."""
|
||||
|
||||
|
||||
class AlreadyConfigured(UnifiException):
|
||||
"""Controller is already configured."""
|
||||
|
||||
|
||||
class AuthenticationRequired(UnifiException):
|
||||
"""Unknown error occurred."""
|
||||
|
||||
|
||||
class CannotConnect(UnifiException):
|
||||
"""Unable to connect to the controller."""
|
||||
|
||||
|
||||
class LoginRequired(UnifiException):
|
||||
"""Component got logged out."""
|
||||
|
||||
|
||||
class UserLevel(UnifiException):
|
||||
"""User level too low."""
|
26
homeassistant/components/unifi/strings.json
Normal file
26
homeassistant/components/unifi/strings.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"config": {
|
||||
"title": "UniFi Controller",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Set up UniFi Controller",
|
||||
"data": {
|
||||
"host": "Host",
|
||||
"username": "User name",
|
||||
"password": "Password",
|
||||
"port": "Port",
|
||||
"site": "Site ID",
|
||||
"verify_ssl": "Controller using proper certificate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"faulty_credentials": "Bad user credentials",
|
||||
"service_unavailable": "No service available"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "Controller site is already configured",
|
||||
"user_privilege": "User needs to be administrator"
|
||||
}
|
||||
}
|
||||
}
|
@ -150,6 +150,7 @@ FLOWS = [
|
||||
'smhi',
|
||||
'sonos',
|
||||
'tradfri',
|
||||
'unifi',
|
||||
'upnp',
|
||||
'zone',
|
||||
'zwave'
|
||||
|
@ -118,6 +118,9 @@ aiolifx_effects==0.2.1
|
||||
# homeassistant.components.scene.hunterdouglas_powerview
|
||||
aiopvapi==1.5.4
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==3
|
||||
|
||||
# homeassistant.components.cover.aladdin_connect
|
||||
aladdin_connect==0.3
|
||||
|
||||
|
@ -40,6 +40,9 @@ aiohttp_cors==0.7.0
|
||||
# homeassistant.components.hue
|
||||
aiohue==1.5.0
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==3
|
||||
|
||||
# homeassistant.components.notify.apns
|
||||
apns2==0.3.0
|
||||
|
||||
|
@ -40,6 +40,7 @@ TEST_REQUIREMENTS = (
|
||||
'aioautomatic',
|
||||
'aiohttp_cors',
|
||||
'aiohue',
|
||||
'aiounifi',
|
||||
'apns2',
|
||||
'caldav',
|
||||
'coinmarketcap',
|
||||
|
345
tests/components/switch/test_unifi.py
Normal file
345
tests/components/switch/test_unifi.py
Normal file
@ -0,0 +1,345 @@
|
||||
"""UniFi POE control platform tests."""
|
||||
from collections import deque
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import aiounifi
|
||||
from aiounifi.clients import Clients
|
||||
from aiounifi.devices import Devices
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import unifi
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
import homeassistant.components.switch as switch
|
||||
|
||||
from tests.common import mock_coro
|
||||
|
||||
CLIENT_1 = {
|
||||
'hostname': 'client_1',
|
||||
'ip': '10.0.0.1',
|
||||
'is_wired': True,
|
||||
'mac': '00:00:00:00:00:01',
|
||||
'name': 'POE Client 1',
|
||||
'oui': 'Producer',
|
||||
'sw_mac': '00:00:00:00:01:01',
|
||||
'sw_port': 1,
|
||||
'wired-rx_bytes': 1234000000,
|
||||
'wired-tx_bytes': 5678000000
|
||||
}
|
||||
CLIENT_2 = {
|
||||
'hostname': 'client_2',
|
||||
'ip': '10.0.0.2',
|
||||
'is_wired': True,
|
||||
'mac': '00:00:00:00:00:02',
|
||||
'name': 'POE Client 2',
|
||||
'oui': 'Producer',
|
||||
'sw_mac': '00:00:00:00:01:01',
|
||||
'sw_port': 2,
|
||||
'wired-rx_bytes': 1234000000,
|
||||
'wired-tx_bytes': 5678000000
|
||||
}
|
||||
CLIENT_3 = {
|
||||
'hostname': 'client_3',
|
||||
'ip': '10.0.0.3',
|
||||
'is_wired': True,
|
||||
'mac': '00:00:00:00:00:03',
|
||||
'name': 'Non-POE Client 3',
|
||||
'oui': 'Producer',
|
||||
'sw_mac': '00:00:00:00:01:01',
|
||||
'sw_port': 3,
|
||||
'wired-rx_bytes': 1234000000,
|
||||
'wired-tx_bytes': 5678000000
|
||||
}
|
||||
CLIENT_4 = {
|
||||
'hostname': 'client_4',
|
||||
'ip': '10.0.0.4',
|
||||
'is_wired': True,
|
||||
'mac': '00:00:00:00:00:04',
|
||||
'name': 'Non-POE Client 4',
|
||||
'oui': 'Producer',
|
||||
'sw_mac': '00:00:00:00:01:01',
|
||||
'sw_port': 4,
|
||||
'wired-rx_bytes': 1234000000,
|
||||
'wired-tx_bytes': 5678000000
|
||||
}
|
||||
POE_SWITCH_CLIENTS = [
|
||||
{
|
||||
'hostname': 'client_1',
|
||||
'ip': '10.0.0.1',
|
||||
'is_wired': True,
|
||||
'mac': '00:00:00:00:00:01',
|
||||
'name': 'POE Client 1',
|
||||
'oui': 'Producer',
|
||||
'sw_mac': '00:00:00:00:01:01',
|
||||
'sw_port': 1,
|
||||
'wired-rx_bytes': 1234000000,
|
||||
'wired-tx_bytes': 5678000000
|
||||
},
|
||||
{
|
||||
'hostname': 'client_2',
|
||||
'ip': '10.0.0.2',
|
||||
'is_wired': True,
|
||||
'mac': '00:00:00:00:00:02',
|
||||
'name': 'POE Client 2',
|
||||
'oui': 'Producer',
|
||||
'sw_mac': '00:00:00:00:01:01',
|
||||
'sw_port': 1,
|
||||
'wired-rx_bytes': 1234000000,
|
||||
'wired-tx_bytes': 5678000000
|
||||
}
|
||||
]
|
||||
|
||||
DEVICE_1 = {
|
||||
'device_id': 'mock-id',
|
||||
'ip': '10.0.1.1',
|
||||
'mac': '00:00:00:00:01:01',
|
||||
'type': 'usw',
|
||||
'name': 'mock-name',
|
||||
'portconf_id': '',
|
||||
'port_table': [
|
||||
{
|
||||
'media': 'GE',
|
||||
'name': 'Port 1',
|
||||
'port_idx': 1,
|
||||
'poe_class': 'Class 4',
|
||||
'poe_enable': True,
|
||||
'poe_mode': 'auto',
|
||||
'poe_power': '2.56',
|
||||
'poe_voltage': '53.40',
|
||||
'portconf_id': '1a1',
|
||||
'port_poe': True,
|
||||
'up': True
|
||||
},
|
||||
{
|
||||
'media': 'GE',
|
||||
'name': 'Port 2',
|
||||
'port_idx': 2,
|
||||
'poe_class': 'Class 4',
|
||||
'poe_enable': True,
|
||||
'poe_mode': 'auto',
|
||||
'poe_power': '2.56',
|
||||
'poe_voltage': '53.40',
|
||||
'portconf_id': '1a2',
|
||||
'port_poe': True,
|
||||
'up': True
|
||||
},
|
||||
{
|
||||
'media': 'GE',
|
||||
'name': 'Port 3',
|
||||
'port_idx': 3,
|
||||
'poe_class': 'Unknown',
|
||||
'poe_enable': False,
|
||||
'poe_mode': 'off',
|
||||
'poe_power': '0.00',
|
||||
'poe_voltage': '0.00',
|
||||
'portconf_id': '1a3',
|
||||
'port_poe': False,
|
||||
'up': True
|
||||
},
|
||||
{
|
||||
'media': 'GE',
|
||||
'name': 'Port 4',
|
||||
'port_idx': 4,
|
||||
'poe_class': 'Unknown',
|
||||
'poe_enable': False,
|
||||
'poe_mode': 'auto',
|
||||
'poe_power': '0.00',
|
||||
'poe_voltage': '0.00',
|
||||
'portconf_id': '1a4',
|
||||
'port_poe': True,
|
||||
'up': True
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
CONTROLLER_DATA = {
|
||||
unifi.CONF_HOST: 'mock-host',
|
||||
unifi.CONF_USERNAME: 'mock-user',
|
||||
unifi.CONF_PASSWORD: 'mock-pswd',
|
||||
unifi.CONF_PORT: 1234,
|
||||
unifi.CONF_SITE_ID: 'mock-site',
|
||||
unifi.CONF_VERIFY_SSL: True
|
||||
}
|
||||
|
||||
ENTRY_CONFIG = {
|
||||
unifi.CONF_CONTROLLER: CONTROLLER_DATA,
|
||||
unifi.CONF_POE_CONTROL: True
|
||||
}
|
||||
|
||||
CONTROLLER_ID = unifi.CONTROLLER_ID.format(host='mock-host', site='mock-site')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_controller(hass):
|
||||
"""Mock a UniFi Controller."""
|
||||
controller = Mock(
|
||||
available=True,
|
||||
api=Mock(),
|
||||
spec=unifi.UniFiController
|
||||
)
|
||||
controller.mock_requests = []
|
||||
|
||||
controller.mock_client_responses = deque()
|
||||
controller.mock_device_responses = deque()
|
||||
|
||||
async def mock_request(method, path, **kwargs):
|
||||
kwargs['method'] = method
|
||||
kwargs['path'] = path
|
||||
controller.mock_requests.append(kwargs)
|
||||
if path == 's/{site}/stat/sta':
|
||||
return controller.mock_client_responses.popleft()
|
||||
if path == 's/{site}/stat/device':
|
||||
return controller.mock_device_responses.popleft()
|
||||
return None
|
||||
|
||||
controller.api.clients = Clients({}, mock_request)
|
||||
controller.api.devices = Devices({}, mock_request)
|
||||
|
||||
return controller
|
||||
|
||||
|
||||
async def setup_controller(hass, mock_controller):
|
||||
"""Load the UniFi switch platform with the provided controller."""
|
||||
hass.config.components.add(unifi.DOMAIN)
|
||||
hass.data[unifi.DOMAIN] = {CONTROLLER_ID: mock_controller}
|
||||
config_entry = config_entries.ConfigEntry(
|
||||
1, unifi.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
|
||||
config_entries.CONN_CLASS_LOCAL_POLL)
|
||||
await hass.config_entries.async_forward_entry_setup(config_entry, 'switch')
|
||||
# To flush out the service call to update the group
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def test_platform_manually_configured(hass):
|
||||
"""Test that we do not discover anything or try to set up a bridge."""
|
||||
assert await async_setup_component(hass, switch.DOMAIN, {
|
||||
'switch': {
|
||||
'platform': 'unifi'
|
||||
}
|
||||
}) is True
|
||||
assert unifi.DOMAIN not in hass.data
|
||||
|
||||
|
||||
async def test_no_clients(hass, mock_controller):
|
||||
"""Test the update_clients function when no clients are found."""
|
||||
mock_controller.mock_client_responses.append({})
|
||||
await setup_controller(hass, mock_controller)
|
||||
assert len(mock_controller.mock_requests) == 2
|
||||
assert not hass.states.async_all()
|
||||
|
||||
|
||||
async def test_switches(hass, mock_controller):
|
||||
"""Test the update_items function with some lights."""
|
||||
mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_4])
|
||||
mock_controller.mock_device_responses.append([DEVICE_1])
|
||||
await setup_controller(hass, mock_controller)
|
||||
assert len(mock_controller.mock_requests) == 2
|
||||
# 1 All Lights group, 2 lights
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
switch_1 = hass.states.get('switch.client_1')
|
||||
assert switch_1 is not None
|
||||
assert switch_1.state == 'on'
|
||||
assert switch_1.attributes['power'] == '2.56'
|
||||
assert switch_1.attributes['received'] == 1234
|
||||
assert switch_1.attributes['sent'] == 5678
|
||||
assert switch_1.attributes['switch'] == '00:00:00:00:01:01'
|
||||
assert switch_1.attributes['port'] == 1
|
||||
assert switch_1.attributes['poe_mode'] == 'auto'
|
||||
|
||||
switch = hass.states.get('switch.client_4')
|
||||
assert switch is None
|
||||
|
||||
|
||||
async def test_new_client_discovered(hass, mock_controller):
|
||||
"""Test if 2nd update has a new client."""
|
||||
mock_controller.mock_client_responses.append([CLIENT_1])
|
||||
mock_controller.mock_device_responses.append([DEVICE_1])
|
||||
|
||||
await setup_controller(hass, mock_controller)
|
||||
assert len(mock_controller.mock_requests) == 2
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_2])
|
||||
mock_controller.mock_device_responses.append([DEVICE_1])
|
||||
|
||||
# Calling a service will trigger the updates to run
|
||||
await hass.services.async_call('switch', 'turn_off', {
|
||||
'entity_id': 'switch.client_1'
|
||||
}, blocking=True)
|
||||
# 2x light update, 1 turn on request
|
||||
assert len(mock_controller.mock_requests) == 5
|
||||
assert len(hass.states.async_all()) == 3
|
||||
|
||||
switch = hass.states.get('switch.client_2')
|
||||
assert switch is not None
|
||||
assert switch.state == 'on'
|
||||
|
||||
|
||||
async def test_failed_update_successful_login(hass, mock_controller):
|
||||
"""Running update can login when requested."""
|
||||
mock_controller.available = False
|
||||
mock_controller.api.clients.update = Mock()
|
||||
mock_controller.api.clients.update.side_effect = aiounifi.LoginRequired
|
||||
mock_controller.api.login = Mock()
|
||||
mock_controller.api.login.return_value = mock_coro()
|
||||
|
||||
await setup_controller(hass, mock_controller)
|
||||
assert len(mock_controller.mock_requests) == 0
|
||||
|
||||
assert mock_controller.available is True
|
||||
|
||||
|
||||
async def test_failed_update_failed_login(hass, mock_controller):
|
||||
"""Running update can handle a failed login."""
|
||||
mock_controller.api.clients.update = Mock()
|
||||
mock_controller.api.clients.update.side_effect = aiounifi.LoginRequired
|
||||
mock_controller.api.login = Mock()
|
||||
mock_controller.api.login.side_effect = aiounifi.AiounifiException
|
||||
|
||||
await setup_controller(hass, mock_controller)
|
||||
assert len(mock_controller.mock_requests) == 0
|
||||
|
||||
assert mock_controller.available is False
|
||||
|
||||
|
||||
async def test_failed_update_unreachable_controller(hass, mock_controller):
|
||||
"""Running update can handle a unreachable controller."""
|
||||
mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_2])
|
||||
mock_controller.mock_device_responses.append([DEVICE_1])
|
||||
|
||||
await setup_controller(hass, mock_controller)
|
||||
|
||||
mock_controller.api.clients.update = Mock()
|
||||
mock_controller.api.clients.update.side_effect = aiounifi.AiounifiException
|
||||
|
||||
# Calling a service will trigger the updates to run
|
||||
await hass.services.async_call('switch', 'turn_off', {
|
||||
'entity_id': 'switch.client_1'
|
||||
}, blocking=True)
|
||||
# 2x light update, 1 turn on request
|
||||
assert len(mock_controller.mock_requests) == 3
|
||||
assert len(hass.states.async_all()) == 3
|
||||
|
||||
assert mock_controller.available is False
|
||||
|
||||
|
||||
async def test_ignore_multiple_poe_clients_on_same_port(hass, mock_controller):
|
||||
"""Ignore when there are multiple POE driven clients on same port.
|
||||
|
||||
If there is a non-UniFi switch powered by POE,
|
||||
clients will be transparently marked as having POE as well.
|
||||
"""
|
||||
mock_controller.mock_client_responses.append(POE_SWITCH_CLIENTS)
|
||||
mock_controller.mock_device_responses.append([DEVICE_1])
|
||||
await setup_controller(hass, mock_controller)
|
||||
assert len(mock_controller.mock_requests) == 2
|
||||
# 1 All Lights group, 2 lights
|
||||
assert len(hass.states.async_all()) == 0
|
||||
|
||||
switch_1 = hass.states.get('switch.client_1')
|
||||
switch_2 = hass.states.get('switch.client_2')
|
||||
assert switch_1 is None
|
||||
assert switch_2 is None
|
1
tests/components/unifi/__init__.py
Normal file
1
tests/components/unifi/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the UniFi component."""
|
266
tests/components/unifi/test_controller.py
Normal file
266
tests/components/unifi/test_controller.py
Normal file
@ -0,0 +1,266 @@
|
||||
"""Test UniFi Controller."""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from homeassistant.components import unifi
|
||||
from homeassistant.components.unifi import controller, errors
|
||||
|
||||
from tests.common import mock_coro
|
||||
|
||||
CONTROLLER_DATA = {
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
unifi.CONF_PORT: 1234,
|
||||
unifi.CONF_SITE_ID: 'site',
|
||||
unifi.CONF_VERIFY_SSL: True
|
||||
}
|
||||
|
||||
ENTRY_CONFIG = {
|
||||
unifi.CONF_CONTROLLER: CONTROLLER_DATA,
|
||||
unifi.CONF_POE_CONTROL: True
|
||||
}
|
||||
|
||||
|
||||
async def test_controller_setup():
|
||||
"""Successful setup."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
api = Mock()
|
||||
api.initialize.return_value = mock_coro(True)
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller',
|
||||
return_value=mock_coro(api)):
|
||||
assert await unifi_controller.async_setup() is True
|
||||
|
||||
assert unifi_controller.api is api
|
||||
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1
|
||||
assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \
|
||||
(entry, 'switch')
|
||||
|
||||
|
||||
async def test_controller_host():
|
||||
"""Config entry host and controller host are the same."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
assert unifi_controller.host == '1.2.3.4'
|
||||
|
||||
|
||||
async def test_controller_mac():
|
||||
"""Test that it is possible to identify controller mac."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
client = Mock()
|
||||
client.ip = '1.2.3.4'
|
||||
client.mac = '00:11:22:33:44:55'
|
||||
api = Mock()
|
||||
api.initialize.return_value = mock_coro(True)
|
||||
api.clients = {'client1': client}
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller',
|
||||
return_value=mock_coro(api)):
|
||||
assert await unifi_controller.async_setup() is True
|
||||
|
||||
assert unifi_controller.mac == '00:11:22:33:44:55'
|
||||
|
||||
|
||||
async def test_controller_no_mac():
|
||||
"""Test that it works to not find the controllers mac."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
client = Mock()
|
||||
client.ip = '5.6.7.8'
|
||||
api = Mock()
|
||||
api.initialize.return_value = mock_coro(True)
|
||||
api.clients = {'client1': client}
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller',
|
||||
return_value=mock_coro(api)):
|
||||
assert await unifi_controller.async_setup() is True
|
||||
|
||||
assert unifi_controller.mac is None
|
||||
|
||||
|
||||
async def test_controller_not_accessible():
|
||||
"""Retry to login gets scheduled when connection fails."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
api = Mock()
|
||||
api.initialize.return_value = mock_coro(True)
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller',
|
||||
side_effect=errors.CannotConnect):
|
||||
assert await unifi_controller.async_setup() is False
|
||||
|
||||
assert len(hass.helpers.event.async_call_later.mock_calls) == 1
|
||||
# Assert we are going to wait 2 seconds
|
||||
assert hass.helpers.event.async_call_later.mock_calls[0][1][0] == 2
|
||||
|
||||
|
||||
async def test_controller_unknown_error():
|
||||
"""Unknown errors are handled."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
api = Mock()
|
||||
api.initialize.return_value = mock_coro(True)
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller', side_effect=Exception):
|
||||
assert await unifi_controller.async_setup() is False
|
||||
|
||||
assert not hass.helpers.event.async_call_later.mock_calls
|
||||
|
||||
|
||||
async def test_reset_cancels_retry_setup():
|
||||
"""Resetting a controller while we're waiting to retry setup."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller',
|
||||
side_effect=errors.CannotConnect):
|
||||
assert await unifi_controller.async_setup() is False
|
||||
|
||||
mock_call_later = hass.helpers.event.async_call_later
|
||||
|
||||
assert len(mock_call_later.mock_calls) == 1
|
||||
|
||||
assert await unifi_controller.async_reset()
|
||||
|
||||
assert len(mock_call_later.mock_calls) == 2
|
||||
assert len(mock_call_later.return_value.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reset_if_entry_had_wrong_auth():
|
||||
"""Calling reset when the entry contains wrong auth."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller',
|
||||
side_effect=errors.AuthenticationRequired):
|
||||
assert await unifi_controller.async_setup() is False
|
||||
|
||||
assert not hass.async_add_job.mock_calls
|
||||
|
||||
assert await unifi_controller.async_reset()
|
||||
|
||||
|
||||
async def test_reset_unloads_entry_if_setup():
|
||||
"""Calling reset when the entry has been setup."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = ENTRY_CONFIG
|
||||
api = Mock()
|
||||
api.initialize.return_value = mock_coro(True)
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller',
|
||||
return_value=mock_coro(api)):
|
||||
assert await unifi_controller.async_setup() is True
|
||||
|
||||
assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1
|
||||
|
||||
hass.config_entries.async_forward_entry_unload.return_value = \
|
||||
mock_coro(True)
|
||||
assert await unifi_controller.async_reset()
|
||||
|
||||
assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_reset_unloads_entry_without_poe_control():
|
||||
"""Calling reset while the entry has been setup."""
|
||||
hass = Mock()
|
||||
entry = Mock()
|
||||
entry.data = dict(ENTRY_CONFIG)
|
||||
entry.data[unifi.CONF_POE_CONTROL] = False
|
||||
api = Mock()
|
||||
api.initialize.return_value = mock_coro(True)
|
||||
|
||||
unifi_controller = controller.UniFiController(hass, entry)
|
||||
|
||||
with patch.object(controller, 'get_controller',
|
||||
return_value=mock_coro(api)):
|
||||
assert await unifi_controller.async_setup() is True
|
||||
|
||||
assert not hass.config_entries.async_forward_entry_setup.mock_calls
|
||||
|
||||
hass.config_entries.async_forward_entry_unload.return_value = \
|
||||
mock_coro(True)
|
||||
assert await unifi_controller.async_reset()
|
||||
|
||||
assert not hass.config_entries.async_forward_entry_unload.mock_calls
|
||||
|
||||
|
||||
async def test_get_controller(hass):
|
||||
"""Successful call."""
|
||||
with patch('aiounifi.Controller.login', return_value=mock_coro()):
|
||||
assert await controller.get_controller(hass, **CONTROLLER_DATA)
|
||||
|
||||
|
||||
async def test_get_controller_verify_ssl_false(hass):
|
||||
"""Successful call with verify ssl set to false."""
|
||||
controller_data = dict(CONTROLLER_DATA)
|
||||
controller_data[unifi.CONF_VERIFY_SSL] = False
|
||||
with patch('aiounifi.Controller.login', return_value=mock_coro()):
|
||||
assert await controller.get_controller(hass, **controller_data)
|
||||
|
||||
|
||||
async def test_get_controller_login_failed(hass):
|
||||
"""Check that get_controller can handle a failed login."""
|
||||
import aiounifi
|
||||
result = None
|
||||
with patch('aiounifi.Controller.login', side_effect=aiounifi.Unauthorized):
|
||||
try:
|
||||
result = await controller.get_controller(hass, **CONTROLLER_DATA)
|
||||
except errors.AuthenticationRequired:
|
||||
pass
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_get_controller_controller_unavailable(hass):
|
||||
"""Check that get_controller can handle controller being unavailable."""
|
||||
import aiounifi
|
||||
result = None
|
||||
with patch('aiounifi.Controller.login',
|
||||
side_effect=aiounifi.RequestError):
|
||||
try:
|
||||
result = await controller.get_controller(hass, **CONTROLLER_DATA)
|
||||
except errors.CannotConnect:
|
||||
pass
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_get_controller_unknown_error(hass):
|
||||
"""Check that get_controller can handle unkown errors."""
|
||||
import aiounifi
|
||||
result = None
|
||||
with patch('aiounifi.Controller.login',
|
||||
side_effect=aiounifi.AiounifiException):
|
||||
try:
|
||||
result = await controller.get_controller(hass, **CONTROLLER_DATA)
|
||||
except errors.AuthenticationRequired:
|
||||
pass
|
||||
assert result is None
|
330
tests/components/unifi/test_init.py
Normal file
330
tests/components/unifi/test_init.py
Normal file
@ -0,0 +1,330 @@
|
||||
"""Test UniFi setup process."""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from homeassistant.components import unifi
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import mock_coro, MockConfigEntry
|
||||
|
||||
|
||||
async def test_setup_with_no_config(hass):
|
||||
"""Test that we do not discover anything or try to set up a bridge."""
|
||||
assert await async_setup_component(hass, unifi.DOMAIN, {}) is True
|
||||
assert unifi.DOMAIN not in hass.data
|
||||
|
||||
|
||||
async def test_successful_config_entry(hass):
|
||||
"""Test that configured options for a host are loaded via config entry."""
|
||||
entry = MockConfigEntry(domain=unifi.DOMAIN, data={
|
||||
'controller': {
|
||||
'host': '0.0.0.0',
|
||||
'username': 'user',
|
||||
'password': 'pass',
|
||||
'port': 80,
|
||||
'site': 'default',
|
||||
'verify_ssl': True
|
||||
},
|
||||
'poe_control': True
|
||||
})
|
||||
entry.add_to_hass(hass)
|
||||
mock_registry = Mock()
|
||||
with patch.object(unifi, 'UniFiController') as mock_controller, \
|
||||
patch('homeassistant.helpers.device_registry.async_get_registry',
|
||||
return_value=mock_coro(mock_registry)):
|
||||
mock_controller.return_value.async_setup.return_value = mock_coro(True)
|
||||
mock_controller.return_value.mac = '00:11:22:33:44:55'
|
||||
assert await unifi.async_setup_entry(hass, entry) is True
|
||||
|
||||
assert len(mock_controller.mock_calls) == 2
|
||||
p_hass, p_entry = mock_controller.mock_calls[0][1]
|
||||
|
||||
assert p_hass is hass
|
||||
assert p_entry is entry
|
||||
|
||||
assert len(mock_registry.mock_calls) == 1
|
||||
assert mock_registry.mock_calls[0][2] == {
|
||||
'config_entry_id': entry.entry_id,
|
||||
'connections': {
|
||||
('mac', '00:11:22:33:44:55')
|
||||
},
|
||||
'manufacturer': 'Ubiquiti',
|
||||
'model': "UniFi Controller",
|
||||
'name': "UniFi Controller",
|
||||
}
|
||||
|
||||
|
||||
async def test_controller_fail_setup(hass):
|
||||
"""Test that configured options for a host are loaded via config entry."""
|
||||
entry = MockConfigEntry(domain=unifi.DOMAIN, data={
|
||||
'controller': {
|
||||
'host': '0.0.0.0',
|
||||
'username': 'user',
|
||||
'password': 'pass',
|
||||
'port': 80,
|
||||
'site': 'default',
|
||||
'verify_ssl': True
|
||||
},
|
||||
'poe_control': True
|
||||
})
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch.object(unifi, 'UniFiController') as mock_cntrlr:
|
||||
mock_cntrlr.return_value.async_setup.return_value = mock_coro(False)
|
||||
assert await unifi.async_setup_entry(hass, entry) is False
|
||||
|
||||
controller_id = unifi.CONTROLLER_ID.format(
|
||||
host='0.0.0.0', site='default'
|
||||
)
|
||||
assert controller_id not in hass.data[unifi.DOMAIN]
|
||||
|
||||
|
||||
async def test_controller_no_mac(hass):
|
||||
"""Test that configured options for a host are loaded via config entry."""
|
||||
entry = MockConfigEntry(domain=unifi.DOMAIN, data={
|
||||
'controller': {
|
||||
'host': '0.0.0.0',
|
||||
'username': 'user',
|
||||
'password': 'pass',
|
||||
'port': 80,
|
||||
'site': 'default',
|
||||
'verify_ssl': True
|
||||
},
|
||||
'poe_control': True
|
||||
})
|
||||
entry.add_to_hass(hass)
|
||||
mock_registry = Mock()
|
||||
with patch.object(unifi, 'UniFiController') as mock_controller, \
|
||||
patch('homeassistant.helpers.device_registry.async_get_registry',
|
||||
return_value=mock_coro(mock_registry)):
|
||||
mock_controller.return_value.async_setup.return_value = mock_coro(True)
|
||||
mock_controller.return_value.mac = None
|
||||
assert await unifi.async_setup_entry(hass, entry) is True
|
||||
|
||||
assert len(mock_controller.mock_calls) == 2
|
||||
|
||||
assert len(mock_registry.mock_calls) == 0
|
||||
|
||||
|
||||
async def test_unload_entry(hass):
|
||||
"""Test being able to unload an entry."""
|
||||
entry = MockConfigEntry(domain=unifi.DOMAIN, data={
|
||||
'controller': {
|
||||
'host': '0.0.0.0',
|
||||
'username': 'user',
|
||||
'password': 'pass',
|
||||
'port': 80,
|
||||
'site': 'default',
|
||||
'verify_ssl': True
|
||||
},
|
||||
'poe_control': True
|
||||
})
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch.object(unifi, 'UniFiController') as mock_controller, \
|
||||
patch('homeassistant.helpers.device_registry.async_get_registry',
|
||||
return_value=mock_coro(Mock())):
|
||||
mock_controller.return_value.async_setup.return_value = mock_coro(True)
|
||||
mock_controller.return_value.mac = '00:11:22:33:44:55'
|
||||
assert await unifi.async_setup_entry(hass, entry) is True
|
||||
|
||||
assert len(mock_controller.return_value.mock_calls) == 1
|
||||
|
||||
mock_controller.return_value.async_reset.return_value = mock_coro(True)
|
||||
assert await unifi.async_unload_entry(hass, entry)
|
||||
assert len(mock_controller.return_value.async_reset.mock_calls) == 1
|
||||
assert hass.data[unifi.DOMAIN] == {}
|
||||
|
||||
|
||||
async def test_flow_works(hass, aioclient_mock):
|
||||
"""Test config flow."""
|
||||
flow = unifi.UnifiFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch('aiounifi.Controller') as mock_controller:
|
||||
def mock_constructor(host, username, password, port, site, websession):
|
||||
"""Fake the controller constructor."""
|
||||
mock_controller.host = host
|
||||
mock_controller.username = username
|
||||
mock_controller.password = password
|
||||
mock_controller.port = port
|
||||
mock_controller.site = site
|
||||
return mock_controller
|
||||
|
||||
mock_controller.side_effect = mock_constructor
|
||||
mock_controller.login.return_value = mock_coro()
|
||||
mock_controller.sites.return_value = mock_coro({
|
||||
'site1': {'name': 'default', 'role': 'admin', 'desc': 'site name'}
|
||||
})
|
||||
|
||||
await flow.async_step_user(user_input={
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
unifi.CONF_PORT: 1234,
|
||||
unifi.CONF_VERIFY_SSL: True
|
||||
})
|
||||
|
||||
result = await flow.async_step_site(user_input={})
|
||||
|
||||
assert mock_controller.host == '1.2.3.4'
|
||||
assert len(mock_controller.login.mock_calls) == 1
|
||||
assert len(mock_controller.sites.mock_calls) == 1
|
||||
|
||||
assert result['type'] == 'create_entry'
|
||||
assert result['title'] == 'site name'
|
||||
assert result['data'] == {
|
||||
unifi.CONF_CONTROLLER: {
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
unifi.CONF_PORT: 1234,
|
||||
unifi.CONF_SITE_ID: 'default',
|
||||
unifi.CONF_VERIFY_SSL: True
|
||||
},
|
||||
unifi.CONF_POE_CONTROL: True
|
||||
}
|
||||
|
||||
|
||||
async def test_controller_multiple_sites(hass):
|
||||
"""Test config flow."""
|
||||
flow = unifi.UnifiFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
flow.config = {
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
}
|
||||
flow.sites = {
|
||||
'site1': {
|
||||
'name': 'default', 'role': 'admin', 'desc': 'site name'
|
||||
},
|
||||
'site2': {
|
||||
'name': 'site2', 'role': 'admin', 'desc': 'site2 name'
|
||||
}
|
||||
}
|
||||
|
||||
result = await flow.async_step_site()
|
||||
|
||||
assert result['type'] == 'form'
|
||||
assert result['step_id'] == 'site'
|
||||
|
||||
assert result['data_schema']({'site': 'site name'})
|
||||
assert result['data_schema']({'site': 'site2 name'})
|
||||
|
||||
|
||||
async def test_controller_site_already_configured(hass):
|
||||
"""Test config flow."""
|
||||
flow = unifi.UnifiFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
entry = MockConfigEntry(domain=unifi.DOMAIN, data={
|
||||
'controller': {
|
||||
'host': '1.2.3.4',
|
||||
'site': 'default',
|
||||
}
|
||||
})
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
flow.config = {
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
}
|
||||
flow.desc = 'site name'
|
||||
flow.sites = {
|
||||
'site1': {
|
||||
'name': 'default', 'role': 'admin', 'desc': 'site name'
|
||||
}
|
||||
}
|
||||
|
||||
result = await flow.async_step_site()
|
||||
|
||||
assert result['type'] == 'abort'
|
||||
|
||||
|
||||
async def test_user_permissions_low(hass, aioclient_mock):
|
||||
"""Test config flow."""
|
||||
flow = unifi.UnifiFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch('aiounifi.Controller') as mock_controller:
|
||||
def mock_constructor(host, username, password, port, site, websession):
|
||||
"""Fake the controller constructor."""
|
||||
mock_controller.host = host
|
||||
mock_controller.username = username
|
||||
mock_controller.password = password
|
||||
mock_controller.port = port
|
||||
mock_controller.site = site
|
||||
return mock_controller
|
||||
|
||||
mock_controller.side_effect = mock_constructor
|
||||
mock_controller.login.return_value = mock_coro()
|
||||
mock_controller.sites.return_value = mock_coro({
|
||||
'site1': {'name': 'default', 'role': 'viewer', 'desc': 'site name'}
|
||||
})
|
||||
|
||||
await flow.async_step_user(user_input={
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
unifi.CONF_PORT: 1234,
|
||||
unifi.CONF_VERIFY_SSL: True
|
||||
})
|
||||
|
||||
result = await flow.async_step_site(user_input={})
|
||||
|
||||
assert result['type'] == 'abort'
|
||||
|
||||
|
||||
async def test_user_credentials_faulty(hass, aioclient_mock):
|
||||
"""Test config flow."""
|
||||
flow = unifi.UnifiFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch.object(unifi, 'get_controller',
|
||||
side_effect=unifi.errors.AuthenticationRequired):
|
||||
result = await flow.async_step_user({
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
unifi.CONF_SITE_ID: 'default',
|
||||
})
|
||||
|
||||
assert result['type'] == 'form'
|
||||
assert result['errors'] == {'base': 'faulty_credentials'}
|
||||
|
||||
|
||||
async def test_controller_is_unavailable(hass, aioclient_mock):
|
||||
"""Test config flow."""
|
||||
flow = unifi.UnifiFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch.object(unifi, 'get_controller',
|
||||
side_effect=unifi.errors.CannotConnect):
|
||||
result = await flow.async_step_user({
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
unifi.CONF_SITE_ID: 'default',
|
||||
})
|
||||
|
||||
assert result['type'] == 'form'
|
||||
assert result['errors'] == {'base': 'service_unavailable'}
|
||||
|
||||
|
||||
async def test_controller_unkown_problem(hass, aioclient_mock):
|
||||
"""Test config flow."""
|
||||
flow = unifi.UnifiFlowHandler()
|
||||
flow.hass = hass
|
||||
|
||||
with patch.object(unifi, 'get_controller',
|
||||
side_effect=Exception):
|
||||
result = await flow.async_step_user({
|
||||
unifi.CONF_HOST: '1.2.3.4',
|
||||
unifi.CONF_USERNAME: 'username',
|
||||
unifi.CONF_PASSWORD: 'password',
|
||||
unifi.CONF_SITE_ID: 'default',
|
||||
})
|
||||
|
||||
assert result['type'] == 'abort'
|
Reference in New Issue
Block a user