From 5b6a7edd8da94a6bc20399ff428051b2c87c494b Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 13 Aug 2023 11:06:12 -0700 Subject: [PATCH] Add Unifi outlet switches for PDU devices (#98320) Updates the Unifi outlet switching feature to support PDU devices --- homeassistant/components/unifi/switch.py | 11 +- tests/components/unifi/test_sensor.py | 2 +- tests/components/unifi/test_switch.py | 223 +++++++++++++++++++++-- 3 files changed, 214 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index ae339eb8d22..a82b9e35d45 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -106,6 +106,15 @@ async def async_dpi_group_control_fn( ) +@callback +def async_outlet_supports_switching_fn( + controller: UniFiController, obj_id: str +) -> bool: + """Determine if an outlet supports switching.""" + outlet = controller.api.outlets[obj_id] + return outlet.has_relay or outlet.caps in (1, 3) + + async def async_outlet_control_fn( api: aiounifi.Controller, obj_id: str, target: bool ) -> None: @@ -210,7 +219,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( name_fn=lambda outlet: outlet.name, object_fn=lambda api, obj_id: api.outlets[obj_id], should_poll=False, - supported_fn=lambda c, obj_id: c.api.outlets[obj_id].has_relay, + supported_fn=async_outlet_supports_switching_fn, unique_id_fn=lambda controller, obj_id: f"{obj_id.split('_', 1)[0]}-outlet-{obj_id.split('_', 1)[1]}", ), UnifiSwitchEntityDescription[Ports, Port]( diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 359825514d7..cf6b74b9765 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -785,7 +785,7 @@ async def test_outlet_power_readings( """Test the outlet power reporting on PDU devices.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) - assert len(hass.states.async_all()) == 7 + assert len(hass.states.async_all()) == 9 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 ent_reg = er.async_get(hass) diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index ad5131614af..5344ac901b7 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -4,6 +4,7 @@ from datetime import timedelta from aiounifi.models.message import MessageKey from aiounifi.websocket import WebsocketState +import pytest from homeassistant import config_entries from homeassistant.components.switch import ( @@ -384,7 +385,7 @@ OUTLET_UP1 = { "x_vwirekey": "2dabb7e23b048c88b60123456789", "vwire_table": [], "dot1x_portctrl_enabled": False, - "outlet_overrides": [], + "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}], "outlet_enabled": True, "license_state": "registered", "x_aes_gcm": True, @@ -580,6 +581,152 @@ OUTLET_UP1 = { } +PDU_DEVICE_1 = { + "_id": "123456654321abcdef012345", + "required_version": "5.28.0", + "port_table": [], + "license_state": "registered", + "lcm_brightness_override": False, + "type": "usw", + "board_rev": 4, + "hw_caps": 136, + "reboot_duration": 70, + "snmp_contact": "", + "config_network": {"type": "dhcp", "bonding_enabled": False}, + "outlet_table": [ + { + "index": 1, + "relay_state": True, + "cycle_enabled": False, + "name": "USB Outlet 1", + "outlet_caps": 1, + }, + { + "index": 2, + "relay_state": True, + "cycle_enabled": False, + "name": "Outlet 2", + "outlet_caps": 3, + "outlet_voltage": "119.644", + "outlet_current": "0.935", + "outlet_power": "73.827", + "outlet_power_factor": "0.659", + }, + ], + "model": "USPPDUP", + "manufacturer_id": 4, + "ip": "192.168.1.76", + "fw2_caps": 0, + "jumboframe_enabled": False, + "version": "6.5.59.14777", + "unsupported_reason": 0, + "adoption_completed": True, + "outlet_enabled": True, + "stp_version": "rstp", + "name": "Dummy USP-PDU-Pro", + "fw_caps": 1732968229, + "lcm_brightness": 80, + "internet": True, + "mgmt_network_id": "123456654321abcdef012347", + "gateway_mac": "01:02:03:04:05:06", + "stp_priority": "32768", + "lcm_night_mode_begins": "22:00", + "two_phase_adopt": False, + "connected_at": 1690626493, + "inform_ip": "192.168.1.1", + "cfgversion": "ba8f30a5a17aad64", + "mac": "01:02:03:04:05:ff", + "provisioned_at": 1690989511, + "inform_url": "http://192.168.1.1:8080/inform", + "upgrade_duration": 100, + "ethernet_table": [{"num_port": 1, "name": "eth0", "mac": "01:02:03:04:05:a1"}], + "flowctrl_enabled": False, + "unsupported": False, + "ble_caps": 0, + "sys_error_caps": 0, + "dot1x_portctrl_enabled": False, + "last_uplink": {}, + "disconnected_at": 1690626452, + "architecture": "mips", + "x_aes_gcm": True, + "has_fan": False, + "outlet_overrides": [ + { + "cycle_enabled": False, + "name": "USB Outlet 1", + "relay_state": True, + "index": 1, + }, + {"cycle_enabled": False, "name": "Outlet 2", "relay_state": True, "index": 2}, + ], + "model_incompatible": False, + "satisfaction": 100, + "model_in_eol": False, + "anomalies": -1, + "has_temperature": False, + "switch_caps": {}, + "adopted_by_client": "web", + "snmp_location": "", + "model_in_lts": False, + "kernel_version": "4.14.115", + "serial": "abc123", + "power_source_ctrl_enabled": False, + "lcm_night_mode_ends": "08:00", + "adopted": True, + "hash_id": "abcdef123456", + "device_id": "mock-pdu", + "uplink": {}, + "state": 1, + "start_disconnected_millis": 1690626383386, + "credential_caps": 0, + "default": False, + "discovered_via": "l2", + "adopt_ip": "10.0.10.4", + "adopt_url": "http://192.168.1.1:8080/inform", + "last_seen": 1691518814, + "min_inform_interval_seconds": 10, + "upgradable": False, + "adoptable_when_upgraded": False, + "rollupgrade": False, + "known_cfgversion": "abcfde03929", + "uptime": 1193042, + "_uptime": 1193042, + "locating": False, + "start_connected_millis": 1690626493324, + "prev_non_busy_state": 5, + "next_interval": 47, + "sys_stats": {}, + "system-stats": {"cpu": "1.4", "mem": "28.9", "uptime": "1193042"}, + "ssh_session_table": [], + "lldp_table": [], + "displayable_version": "6.5.59", + "connection_network_id": "123456654321abcdef012349", + "connection_network_name": "Default", + "startup_timestamp": 1690325774, + "is_access_point": False, + "safe_for_autoupgrade": True, + "overheating": False, + "power_source": "0", + "total_max_power": 0, + "outlet_ac_power_budget": "1875.000", + "outlet_ac_power_consumption": "201.683", + "downlink_table": [], + "uplink_depth": 1, + "downlink_lldp_macs": [], + "dhcp_server_table": [], + "connect_request_ip": "10.0.10.4", + "connect_request_port": "57951", + "ipv4_lease_expiration_timestamp_seconds": 1691576686, + "stat": {}, + "tx_bytes": 1426780, + "rx_bytes": 1435064, + "bytes": 2861844, + "num_sta": 0, + "user-num_sta": 0, + "guest-num_sta": 0, + "x_has_ssh_hostkey": True, +} + WLAN = { "_id": "012345678910111213141516", "bc_filter_enabled": False, @@ -960,56 +1107,92 @@ async def test_dpi_switches_add_second_app( assert hass.states.get("switch.block_media_streaming").state == STATE_ON +@pytest.mark.parametrize( + ("entity_id", "test_data", "outlet_index", "expected_switches"), + [ + ( + "plug_outlet_1", + OUTLET_UP1, + 1, + 1, + ), + ( + "dummy_usp_pdu_pro_usb_outlet_1", + PDU_DEVICE_1, + 1, + 2, + ), + ( + "dummy_usp_pdu_pro_outlet_2", + PDU_DEVICE_1, + 2, + 2, + ), + ], +) async def test_outlet_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + entity_id: str, + test_data: any, + outlet_index: int, + expected_switches: int, ) -> None: """Test the outlet entities.""" config_entry = await setup_unifi_integration( - hass, aioclient_mock, devices_response=[OUTLET_UP1] + hass, aioclient_mock, devices_response=[test_data] ) controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 - + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == expected_switches # Validate state object - switch_1 = hass.states.get("switch.plug_outlet_1") + switch_1 = hass.states.get(f"switch.{entity_id}") 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(OUTLET_UP1) - device_1["outlet_table"][0]["relay_state"] = False + device_1 = deepcopy(test_data) + device_1["outlet_table"][outlet_index - 1]["relay_state"] = False mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Turn off outlet + device_id = test_data["device_id"] aioclient_mock.clear_requests() aioclient_mock.put( - f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/600c8356942a6ade50707b56", + f"https://{controller.host}:1234/api/s/{controller.site}/rest/device/{device_id}", ) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, + {ATTR_ENTITY_ID: f"switch.{entity_id}"}, blocking=True, ) + + expected_off_overrides = deepcopy(device_1["outlet_overrides"]) + expected_off_overrides[outlet_index - 1]["relay_state"] = False + assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[0][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": False}] + "outlet_overrides": expected_off_overrides } # Turn on outlet await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.plug_outlet_1"}, + {ATTR_ENTITY_ID: f"switch.{entity_id}"}, blocking=True, ) + + expected_on_overrides = deepcopy(device_1["outlet_overrides"]) + expected_on_overrides[outlet_index - 1]["relay_state"] = True assert aioclient_mock.call_count == 2 assert aioclient_mock.mock_calls[1][2] == { - "outlet_overrides": [{"index": 1, "name": "Outlet 1", "relay_state": True}] + "outlet_overrides": expected_on_overrides } # Availability signalling @@ -1017,33 +1200,33 @@ async def test_outlet_switches( # Controller disconnects mock_unifi_websocket(state=WebsocketState.DISCONNECTED) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Controller reconnects mock_unifi_websocket(state=WebsocketState.RUNNING) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Device gets disabled device_1["disabled"] = True mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Device gets re-enabled device_1["disabled"] = False mock_unifi_websocket(message=MessageKey.DEVICE, data=device_1) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1").state == STATE_OFF + assert hass.states.get(f"switch.{entity_id}").state == STATE_OFF # Unload config entry await hass.config_entries.async_unload(config_entry.entry_id) - assert hass.states.get("switch.plug_outlet_1").state == STATE_UNAVAILABLE + assert hass.states.get(f"switch.{entity_id}").state == STATE_UNAVAILABLE # Remove config entry await hass.config_entries.async_remove(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get("switch.plug_outlet_1") is None + assert hass.states.get(f"switch.{entity_id}") is None async def test_new_client_discovered_on_block_control(