From 04cdcad7f8e3ea9eb838b7d9449011e5fec10999 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 19 Oct 2022 19:54:40 +0200 Subject: [PATCH] Expose UniFi PoE ports as individual switches (#80566) * Add simple PoE control switches * Add basic tests * Complete testing * Dont use port.up as part of available * Bump aiounifi to v40 --- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/switch.py | 87 ++++++++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_switch.py | 126 ++++++++++++++++++- 5 files changed, 213 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 365ce086fb0..56b31b669e2 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Network", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==39"], + "requirements": ["aiounifi==40"], "codeowners": ["@Kane610"], "quality_scale": "platinum", "ssdp": [ diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index fefcfcc56c6..df935822c5a 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -8,7 +8,7 @@ Support for controlling deep packet inspection (DPI) restriction groups. import asyncio from typing import Any -from aiounifi.interfaces.api_handlers import SOURCE_EVENT +from aiounifi.interfaces.api_handlers import SOURCE_EVENT, ItemEvent from aiounifi.models.client import ClientBlockRequest from aiounifi.models.device import ( DeviceSetOutletRelayRequest, @@ -111,6 +111,18 @@ async def async_setup_entry( items_added() known_poe_clients.clear() + @callback + def async_add_poe_switch(_: ItemEvent, obj_id: str) -> None: + """Add port PoE switch from UniFi controller.""" + if not controller.api.ports[obj_id].port_poe: + return + async_add_entities([UnifiPoePortSwitch(obj_id, controller)]) + + controller.api.ports.subscribe(async_add_poe_switch, ItemEvent.ADDED) + + for port_idx in controller.api.ports: + async_add_poe_switch(ItemEvent.ADDED, port_idx) + @callback def add_block_entities(controller, async_add_entities, clients): @@ -550,3 +562,76 @@ class UniFiOutletSwitch(UniFiBase, SwitchEntity): async def options_updated(self) -> None: """Config entry options are updated, no options to act on.""" + + +class UnifiPoePortSwitch(SwitchEntity): + """Representation of a Power-over-Ethernet source port on an UniFi device.""" + + _attr_device_class = SwitchDeviceClass.OUTLET + _attr_entity_category = EntityCategory.CONFIG + _attr_entity_registry_enabled_default = False + _attr_has_entity_name = True + _attr_icon = "mdi:ethernet" + _attr_should_poll = False + + def __init__(self, obj_id: str, controller) -> None: + """Set up UniFi Network entity base.""" + self._attr_unique_id = f"{obj_id}-PoE" + self._device_mac, port_idx = obj_id.split("_", 1) + self._port_idx = int(port_idx) + self._obj_id = obj_id + self.controller = controller + + port = self.controller.api.ports[self._obj_id] + self._attr_name = f"{port.name} PoE" + self._attr_is_on = port.poe_mode != "off" + + device = self.controller.api.devices[self._device_mac] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, device.mac)}, + manufacturer=ATTR_MANUFACTURER, + model=device.model, + name=device.name or None, + sw_version=device.version, + ) + + async def async_added_to_hass(self) -> None: + """Entity created.""" + self.async_on_remove( + self.controller.api.ports.subscribe(self.async_signalling_callback) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + self.controller.signal_reachable, + self.async_signal_reachable_callback, + ) + ) + + @callback + def async_signalling_callback(self, event: ItemEvent, obj_id: str) -> None: + """Object has new event.""" + device = self.controller.api.devices[self._device_mac] + port = self.controller.api.ports[self._obj_id] + self._attr_available = self.controller.available and not device.disabled + self._attr_is_on = port.poe_mode != "off" + self.async_write_ha_state() + + @callback + def async_signal_reachable_callback(self) -> None: + """Call when controller connection state change.""" + self.async_signalling_callback(ItemEvent.ADDED, self._obj_id) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable POE for client.""" + device = self.controller.api.devices[self._device_mac] + await self.controller.api.request( + DeviceSetPoePortModeRequest.create(device, self._port_idx, "auto") + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable POE for client.""" + device = self.controller.api.devices[self._device_mac] + await self.controller.api.request( + DeviceSetPoePortModeRequest.create(device, self._port_idx, "off") + ) diff --git a/requirements_all.txt b/requirements_all.txt index 8a1320eaa17..f47a777466e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -276,7 +276,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==39 +aiounifi==40 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b3b488ca15..ee41f529787 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -251,7 +251,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==39 +aiounifi==40 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 9965c25a1b5..41494114c1e 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -1,13 +1,18 @@ """UniFi Network switch platform tests.""" + from copy import deepcopy +from datetime import timedelta from aiounifi.controller import MESSAGE_CLIENT_REMOVED, MESSAGE_DEVICE, MESSAGE_EVENT +from aiounifi.models.message import MessageKey +from aiounifi.websocket import WebsocketState from homeassistant import config_entries, core from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SwitchDeviceClass, ) from homeassistant.components.unifi.const import ( CONF_BLOCK_CLIENT, @@ -18,10 +23,19 @@ from homeassistant.components.unifi.const import ( DOMAIN as UNIFI_DOMAIN, ) from homeassistant.components.unifi.switch import POE_SWITCH -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_registry import RegistryEntryDisabler +from homeassistant.util import dt from .test_controller import ( CONTROLLER_HOST, @@ -31,7 +45,7 @@ from .test_controller import ( setup_unifi_integration, ) -from tests.common import mock_restore_cache +from tests.common import async_fire_time_changed, mock_restore_cache CLIENT_1 = { "hostname": "client_1", @@ -1373,3 +1387,111 @@ async def test_restore_client_no_old_state(hass, aioclient_mock): poe_client = hass.states.get("switch.poe_client") assert poe_client.state == "unavailable" # self.poe_mode is None + + +async def test_poe_port_switches(hass, aioclient_mock, mock_unifi_websocket): + """Test the update_items function with some clients.""" + config_entry = await setup_unifi_integration( + hass, aioclient_mock, devices_response=[DEVICE_1] + ) + controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 + + ent_reg = er.async_get(hass) + ent_reg_entry = ent_reg.async_get("switch.mock_name_port_1_poe") + assert ent_reg_entry.disabled_by == RegistryEntryDisabler.INTEGRATION + assert ent_reg_entry.entity_category is EntityCategory.CONFIG + + # Enable entity + ent_reg.async_update_entity( + entity_id="switch.mock_name_port_1_poe", disabled_by=None + ) + await hass.async_block_till_done() + + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + # Validate state object + switch_1 = hass.states.get("switch.mock_name_port_1_poe") + assert switch_1 is not None + assert switch_1.state == STATE_ON + assert switch_1.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET + + # Update state object + device_1 = deepcopy(DEVICE_1) + device_1["port_table"][0]["poe_mode"] = "off" + mock_unifi_websocket( + data={ + "meta": {"message": MessageKey.DEVICE.value}, + "data": [device_1], + } + ) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF + + # Turn off PoE + aioclient_mock.clear_requests() + aioclient_mock.put( + f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/mock-id", + ) + + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_off", + {"entity_id": "switch.mock_name_port_1_poe"}, + blocking=True, + ) + assert aioclient_mock.call_count == 1 + assert aioclient_mock.mock_calls[0][2] == { + "port_overrides": [{"poe_mode": "off", "port_idx": 1, "portconf_id": "1a1"}] + } + + # Turn on PoE + await hass.services.async_call( + SWITCH_DOMAIN, + "turn_on", + {"entity_id": "switch.mock_name_port_1_poe"}, + blocking=True, + ) + assert aioclient_mock.call_count == 2 + assert aioclient_mock.mock_calls[1][2] == { + "port_overrides": [{"poe_mode": "auto", "port_idx": 1, "portconf_id": "1a1"}] + } + + # Availability signalling + + # Controller disconnects + mock_unifi_websocket(state=WebsocketState.DISCONNECTED) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE + + # Controller reconnects + mock_unifi_websocket(state=WebsocketState.RUNNING) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF + + # Device gets disabled + device_1["disabled"] = True + mock_unifi_websocket( + data={ + "meta": {"message": MessageKey.DEVICE.value}, + "data": [device_1], + } + ) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_UNAVAILABLE + + # Device gets re-enabled + device_1["disabled"] = False + mock_unifi_websocket( + data={ + "meta": {"message": MessageKey.DEVICE.value}, + "data": [device_1], + } + ) + await hass.async_block_till_done() + assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF