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 v40pull/80630/head
parent
2b2275dfb3
commit
04cdcad7f8
|
@ -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": [
|
||||
|
|
|
@ -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")
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue