From 87753bdb825d3da601736fe46e4ad3d16c4b972a Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 12 Aug 2023 09:12:59 -0700 Subject: [PATCH] Add UniFi power stats for PDU overall AC outlet metrics (#98217) --- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/sensor.py | 48 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_sensor.py | 91 ++++++++++++++++---- 5 files changed, 126 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 3b1fa68638b..8f27263b288 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["aiounifi"], "quality_scale": "platinum", - "requirements": ["aiounifi==52"], + "requirements": ["aiounifi==53"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 367ff1332f4..142bd587853 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -12,11 +12,13 @@ from typing import Generic from aiounifi.interfaces.api_handlers import ItemEvent from aiounifi.interfaces.clients import Clients +from aiounifi.interfaces.devices import Devices from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.ports import Ports from aiounifi.interfaces.wlans import Wlans from aiounifi.models.api import ApiItemT from aiounifi.models.client import Client +from aiounifi.models.device import Device from aiounifi.models.outlet import Outlet from aiounifi.models.port import Port from aiounifi.models.wlan import Wlan @@ -96,6 +98,12 @@ def async_device_outlet_power_supported_fn( return controller.api.outlets[obj_id].caps == 3 +@callback +def async_device_outlet_supported_fn(controller: UniFiController, obj_id: str) -> bool: + """Determine if a device supports reading overall power metrics.""" + return controller.api.devices[obj_id].outlet_ac_power_budget is not None + + @dataclass class UnifiSensorEntityDescriptionMixin(Generic[HandlerT, ApiItemT]): """Validate and load entities from different UniFi handlers.""" @@ -224,6 +232,46 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( unique_id_fn=lambda controller, obj_id: f"outlet_power-{obj_id}", value_fn=lambda _, obj: obj.power if obj.relay_state else "0", ), + UnifiSensorEntityDescription[Devices, Device]( + key="SmartPower AC power budget", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=1, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "AC Power Budget", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=async_device_outlet_supported_fn, + unique_id_fn=lambda controller, obj_id: f"ac_power_budget-{obj_id}", + value_fn=lambda controller, device: device.outlet_ac_power_budget, + ), + UnifiSensorEntityDescription[Devices, Device]( + key="SmartPower AC power consumption", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfPower.WATT, + suggested_display_precision=1, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "AC Power Consumption", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=async_device_outlet_supported_fn, + unique_id_fn=lambda controller, obj_id: f"ac_power_conumption-{obj_id}", + value_fn=lambda controller, device: device.outlet_ac_power_consumption, + ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 8eae7bc175f..aa512d9ffd5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -360,7 +360,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==52 +aiounifi==53 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7048126d3a8..3818300ea82 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,7 +335,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==52 +aiounifi==53 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 98a4941caaa..359825514d7 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -278,6 +278,27 @@ PDU_DEVICE_1 = { "x_has_ssh_hostkey": True, } +PDU_OUTLETS_UPDATE_DATA = [ + { + "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": "123.45", + "outlet_power_factor": "0.659", + }, +] + async def test_no_clients( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker @@ -719,31 +740,69 @@ async def test_wlan_client_sensors( assert hass.states.get("sensor.ssid_1").state == "0" +@pytest.mark.parametrize( + ( + "entity_id", + "expected_unique_id", + "expected_value", + "changed_data", + "expected_update_value", + ), + [ + ( + "dummy_usp_pdu_pro_outlet_2_outlet_power", + "outlet_power-01:02:03:04:05:ff_2", + "73.827", + {"outlet_table": PDU_OUTLETS_UPDATE_DATA}, + "123.45", + ), + ( + "dummy_usp_pdu_pro_ac_power_budget", + "ac_power_budget-01:02:03:04:05:ff", + "1875.000", + None, + None, + ), + ( + "dummy_usp_pdu_pro_ac_power_consumption", + "ac_power_conumption-01:02:03:04:05:ff", + "201.683", + {"outlet_ac_power_consumption": "456.78"}, + "456.78", + ), + ], +) async def test_outlet_power_readings( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_unifi_websocket, + entity_id: str, + expected_unique_id: str, + expected_value: any, + changed_data: dict | None, + expected_update_value: any, ) -> None: """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()) == 5 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_all()) == 7 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 ent_reg = er.async_get(hass) - ent_reg_entry = ent_reg.async_get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power") - assert ent_reg_entry.unique_id == "outlet_power-01:02:03:04:05:ff_2" + ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}") + assert ent_reg_entry.unique_id == expected_unique_id assert ent_reg_entry.entity_category is EntityCategory.DIAGNOSTIC - outlet_2 = hass.states.get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power") - assert outlet_2.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER - assert outlet_2.state == "73.827" + sensor_data = hass.states.get(f"sensor.{entity_id}") + assert sensor_data.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER + assert sensor_data.state == expected_value - # Verify state update - pdu_device_state_update = deepcopy(PDU_DEVICE_1) + if changed_data is not None: + updated_device_data = deepcopy(PDU_DEVICE_1) + updated_device_data.update(changed_data) - pdu_device_state_update["outlet_table"][1]["outlet_power"] = "123.45" + mock_unifi_websocket(message=MessageKey.DEVICE, data=updated_device_data) + await hass.async_block_till_done() - mock_unifi_websocket(message=MessageKey.DEVICE, data=pdu_device_state_update) - await hass.async_block_till_done() - - outlet_2 = hass.states.get("sensor.dummy_usp_pdu_pro_outlet_2_outlet_power") - assert outlet_2.state == "123.45" + sensor_data = hass.states.get(f"sensor.{entity_id}") + assert sensor_data.state == expected_update_value