Add metered PDU dynamic outlet sensors to NUT (#140179)

* Add metered PDU dynamic outlet sensors

* Make deep copy and improve efficiency of loops

* Improve performance by creating new dict

Co-authored-by: J. Nick Koston <nick+github@koston.org>

* Remove unused import copy

* Use outlet name (if available) in friendly name and remove as separate sensor

---------

Co-authored-by: J. Nick Koston <nick+github@koston.org>
This commit is contained in:
tdfountain 2025-03-21 14:41:43 -07:00 committed by GitHub
parent 84c6fa256c
commit 2571725eb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 153 additions and 19 deletions

View File

@ -67,6 +67,21 @@
"input_voltage_status": {
"default": "mdi:information-outline"
},
"outlet_number_current": {
"default": "mdi:gauge"
},
"outlet_number_current_status": {
"default": "mdi:information-outline"
},
"outlet_number_desc": {
"default": "mdi:information-outline"
},
"outlet_number_power": {
"default": "mdi:gauge"
},
"outlet_number_realpower": {
"default": "mdi:gauge"
},
"outlet_voltage": {
"default": "mdi:gauge"
},

View File

@ -1029,6 +1029,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the NUT sensors."""
valid_sensor_types: dict[str, SensorEntityDescription]
pynut_data = config_entry.runtime_data
coordinator = pynut_data.coordinator
@ -1036,20 +1037,75 @@ async def async_setup_entry(
unique_id = pynut_data.unique_id
status = coordinator.data
resources = [sensor_id for sensor_id in SENSOR_TYPES if sensor_id in status]
# Display status is a special case that falls back to the status value
# of the UPS instead.
if KEY_STATUS in resources:
resources.append(KEY_STATUS_DISPLAY)
# Dynamically add outlet sensors to valid sensors dictionary
if (num_outlets := status.get("outlet.count")) is not None:
additional_sensor_types: dict[str, SensorEntityDescription] = {}
for outlet_num in range(1, int(num_outlets) + 1):
outlet_num_str: str = str(outlet_num)
outlet_name: str = (
status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str
)
additional_sensor_types |= {
f"outlet.{outlet_num_str}.current": SensorEntityDescription(
key=f"outlet.{outlet_num_str}.current",
translation_key="outlet_number_current",
translation_placeholders={"outlet_name": outlet_name},
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
f"outlet.{outlet_num_str}.current_status": SensorEntityDescription(
key=f"outlet.{outlet_num_str}.current_status",
translation_key="outlet_number_current_status",
translation_placeholders={"outlet_name": outlet_name},
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
f"outlet.{outlet_num_str}.desc": SensorEntityDescription(
key=f"outlet.{outlet_num_str}.desc",
translation_key="outlet_number_desc",
translation_placeholders={"outlet_name": outlet_name},
),
f"outlet.{outlet_num_str}.power": SensorEntityDescription(
key=f"outlet.{outlet_num_str}.power",
translation_key="outlet_number_power",
translation_placeholders={"outlet_name": outlet_name},
native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
device_class=SensorDeviceClass.APPARENT_POWER,
state_class=SensorStateClass.MEASUREMENT,
),
f"outlet.{outlet_num_str}.realpower": SensorEntityDescription(
key=f"outlet.{outlet_num_str}.realpower",
translation_key="outlet_number_realpower",
translation_placeholders={"outlet_name": outlet_name},
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
}
valid_sensor_types = {**SENSOR_TYPES, **additional_sensor_types}
else:
valid_sensor_types = SENSOR_TYPES
# If device reports ambient sensors are not present, then remove
if status.get(AMBIENT_PRESENT) == "no":
resources = [item for item in resources if item not in AMBIENT_SENSORS]
has_ambient_sensors: bool = status.get(AMBIENT_PRESENT) != "no"
resources = [
sensor_id
for sensor_id in valid_sensor_types
if sensor_id in status
and (has_ambient_sensors or sensor_id not in AMBIENT_SENSORS)
]
# Display status is a special case that falls back to the status value
# of the UPS instead.
if KEY_STATUS in status:
resources.append(KEY_STATUS_DISPLAY)
async_add_entities(
NUTSensor(
coordinator,
SENSOR_TYPES[sensor_type],
valid_sensor_types[sensor_type],
data,
unique_id,
)

View File

@ -157,6 +157,13 @@
"input_l1_n_voltage": { "name": "Input L1 voltage" },
"input_l2_n_voltage": { "name": "Input L2 voltage" },
"input_l3_n_voltage": { "name": "Input L3 voltage" },
"outlet_number_current": { "name": "Outlet {outlet_name} current" },
"outlet_number_current_status": {
"name": "Outlet {outlet_name} current status"
},
"outlet_number_desc": { "name": "Outlet {outlet_name} description" },
"outlet_number_power": { "name": "Outlet {outlet_name} power" },
"outlet_number_realpower": { "name": "Outlet {outlet_name} real power" },
"outlet_voltage": { "name": "Outlet voltage" },
"output_current": { "name": "Output current" },
"output_current_nominal": { "name": "Nominal output current" },

View File

@ -12,6 +12,7 @@ from homeassistant.const import (
CONF_RESOURCES,
PERCENTAGE,
STATE_UNKNOWN,
UnitOfElectricCurrent,
UnitOfElectricPotential,
)
from homeassistant.core import HomeAssistant
@ -103,7 +104,7 @@ async def test_ups_devices_with_unique_ids(
[
(
"EATON-EPDU-G3",
"EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_",
"EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000",
),
],
)
@ -115,11 +116,13 @@ async def test_pdu_devices_with_unique_ids(
) -> None:
"""Test creation of device sensors with unique ids."""
await _test_sensor_and_attributes(
await async_init_integration(hass, model)
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}input.voltage",
unique_id=f"{unique_id_base}_input.voltage",
device_id="sensor.ups1_input_voltage",
state_value="122.91",
expected_attributes={
@ -130,11 +133,11 @@ async def test_pdu_devices_with_unique_ids(
},
)
await _test_sensor_and_attributes(
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}ambient.humidity.status",
unique_id=f"{unique_id_base}_ambient.humidity.status",
device_id="sensor.ups1_ambient_humidity_status",
state_value="good",
expected_attributes={
@ -143,11 +146,11 @@ async def test_pdu_devices_with_unique_ids(
},
)
await _test_sensor_and_attributes(
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}ambient.temperature.status",
unique_id=f"{unique_id_base}_ambient.temperature.status",
device_id="sensor.ups1_ambient_temperature_status",
state_value="good",
expected_attributes={
@ -248,7 +251,7 @@ async def test_stale_options(
[
(
"EATON-EPDU-G3-AMBIENT-NOT-PRESENT",
"EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_",
"EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000",
),
],
)
@ -273,3 +276,57 @@ async def test_pdu_devices_ambient_not_present(
entry = entity_registry.async_get("sensor.ups1_ambient_temperature_status")
assert not entry
@pytest.mark.parametrize(
("model", "unique_id_base"),
[
(
"EATON-EPDU-G3",
"EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000",
),
],
)
async def test_pdu_dynamic_outlets(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
model: str,
unique_id_base: str,
) -> None:
"""Test for dynamically created outlet sensors."""
await async_init_integration(hass, model)
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}_outlet.1.current",
device_id="sensor.ups1_outlet_a1_current",
state_value="0",
expected_attributes={
"device_class": SensorDeviceClass.CURRENT,
"friendly_name": "Ups1 Outlet A1 current",
"unit_of_measurement": UnitOfElectricCurrent.AMPERE,
},
)
_test_sensor_and_attributes(
hass,
entity_registry,
model,
unique_id=f"{unique_id_base}_outlet.24.current",
device_id="sensor.ups1_outlet_a24_current",
state_value="0.19",
expected_attributes={
"device_class": SensorDeviceClass.CURRENT,
"friendly_name": "Ups1 Outlet A24 current",
"unit_of_measurement": UnitOfElectricCurrent.AMPERE,
},
)
entry = entity_registry.async_get("sensor.ups1_outlet_25_current")
assert not entry
entry = entity_registry.async_get("sensor.ups1_outlet_a25_current")
assert not entry

View File

@ -82,7 +82,7 @@ async def async_init_integration(
return entry
async def _test_sensor_and_attributes(
def _test_sensor_and_attributes(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
model: str,
@ -91,9 +91,8 @@ async def _test_sensor_and_attributes(
state_value: str,
expected_attributes: dict,
) -> None:
"""Test creation of device sensors with unique ids."""
"""Test all of the sensor entry attributes."""
await async_init_integration(hass, model)
entry = entity_registry.async_get(device_id)
assert entry
assert entry.unique_id == unique_id