Skip to content

Commit ddd67a7

Browse files
authored
Add PDU dynamic outlet buttons to NUT (#140317)
1 parent a9df341 commit ddd67a7

File tree

8 files changed

+207
-21
lines changed

8 files changed

+207
-21
lines changed

homeassistant/components/nut/__init__.py

+22-4
Original file line numberDiff line numberDiff line change
@@ -103,22 +103,40 @@ async def async_update_data() -> dict[str, str]:
103103
)
104104
status = coordinator.data
105105

106-
_LOGGER.debug("NUT Sensors Available: %s", status)
106+
_LOGGER.debug("NUT Sensors Available: %s", status if status else None)
107107

108108
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
109109
unique_id = _unique_id_from_status(status)
110110
if unique_id is None:
111111
unique_id = entry.entry_id
112112

113113
if username is not None and password is not None:
114+
# Dynamically add outlet integration commands
115+
additional_integration_commands = set()
116+
if (num_outlets := status.get("outlet.count")) is not None:
117+
for outlet_num in range(1, int(num_outlets) + 1):
118+
outlet_num_str: str = str(outlet_num)
119+
additional_integration_commands |= {
120+
f"outlet.{outlet_num_str}.load.cycle",
121+
}
122+
123+
valid_integration_commands = (
124+
INTEGRATION_SUPPORTED_COMMANDS | additional_integration_commands
125+
)
126+
114127
user_available_commands = {
115-
device_supported_command
116-
for device_supported_command in await data.async_list_commands() or {}
117-
if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS
128+
device_command
129+
for device_command in await data.async_list_commands() or {}
130+
if device_command in valid_integration_commands
118131
}
119132
else:
120133
user_available_commands = set()
121134

135+
_LOGGER.debug(
136+
"NUT Commands Available: %s",
137+
user_available_commands if user_available_commands else None,
138+
)
139+
122140
entry.runtime_data = NutRuntimeData(
123141
coordinator, data, unique_id, user_available_commands
124142
)
+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Provides a switch for switchable NUT outlets."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
7+
from homeassistant.components.button import (
8+
ButtonDeviceClass,
9+
ButtonEntity,
10+
ButtonEntityDescription,
11+
)
12+
from homeassistant.core import HomeAssistant
13+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
14+
15+
from . import NutConfigEntry
16+
from .entity import NUTBaseEntity
17+
18+
_LOGGER = logging.getLogger(__name__)
19+
20+
21+
async def async_setup_entry(
22+
hass: HomeAssistant,
23+
config_entry: NutConfigEntry,
24+
async_add_entities: AddConfigEntryEntitiesCallback,
25+
) -> None:
26+
"""Set up the NUT buttons."""
27+
pynut_data = config_entry.runtime_data
28+
coordinator = pynut_data.coordinator
29+
status = coordinator.data
30+
31+
# Dynamically add outlet button types
32+
if (num_outlets := status.get("outlet.count")) is None:
33+
return
34+
35+
data = pynut_data.data
36+
unique_id = pynut_data.unique_id
37+
valid_button_types: dict[str, ButtonEntityDescription] = {}
38+
for outlet_num in range(1, int(num_outlets) + 1):
39+
outlet_num_str = str(outlet_num)
40+
outlet_name: str = status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str
41+
valid_button_types |= {
42+
f"outlet.{outlet_num_str}.load.cycle": ButtonEntityDescription(
43+
key=f"outlet.{outlet_num_str}.load.cycle",
44+
translation_key="outlet_number_load_cycle",
45+
translation_placeholders={"outlet_name": outlet_name},
46+
device_class=ButtonDeviceClass.RESTART,
47+
entity_registry_enabled_default=True,
48+
),
49+
}
50+
51+
async_add_entities(
52+
NUTButton(coordinator, description, data, unique_id)
53+
for button_id, description in valid_button_types.items()
54+
if button_id in pynut_data.user_available_commands
55+
)
56+
57+
58+
class NUTButton(NUTBaseEntity, ButtonEntity):
59+
"""Representation of a button entity for NUT."""
60+
61+
async def async_press(self) -> None:
62+
"""Press the button."""
63+
name_list = self.entity_description.key.split(".")
64+
command_name = f"{name_list[0]}.{name_list[1]}.load.cycle"
65+
await self.pynut_data.async_run_command(command_name)

homeassistant/components/nut/const.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66

77
DOMAIN = "nut"
88

9-
PLATFORMS = [Platform.SENSOR]
9+
PLATFORMS = [
10+
Platform.BUTTON,
11+
Platform.SENSOR,
12+
]
1013

1114
DEFAULT_NAME = "NUT UPS"
1215
DEFAULT_HOST = "localhost"

homeassistant/components/nut/entity.py

+5
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
ATTR_SW_VERSION,
1313
)
1414
from homeassistant.helpers.device_registry import DeviceInfo
15+
from homeassistant.helpers.entity import EntityDescription
1516
from homeassistant.helpers.update_coordinator import (
1617
CoordinatorEntity,
1718
DataUpdateCoordinator,
@@ -36,12 +37,16 @@ class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]):
3637
def __init__(
3738
self,
3839
coordinator: DataUpdateCoordinator,
40+
entity_description: EntityDescription,
3941
data: PyNUTData,
4042
unique_id: str,
4143
) -> None:
4244
"""Initialize the entity."""
4345
super().__init__(coordinator)
4446

47+
self.entity_description = entity_description
48+
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
49+
4550
self.pynut_data = data
4651
self._attr_device_info = DeviceInfo(
4752
identifiers={(DOMAIN, unique_id)},

homeassistant/components/nut/icons.json

+5
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,11 @@
151151
"ups_watchdog_status": {
152152
"default": "mdi:information-outline"
153153
}
154+
},
155+
"button": {
156+
"outlet_number_load_cycle": {
157+
"default": "mdi:restart"
158+
}
154159
}
155160
}
156161
}

homeassistant/components/nut/sensor.py

+1-16
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,8 @@
2525
)
2626
from homeassistant.core import HomeAssistant
2727
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
28-
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
2928

30-
from . import NutConfigEntry, PyNUTData
29+
from . import NutConfigEntry
3130
from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES
3231
from .entity import NUTBaseEntity
3332

@@ -1089,20 +1088,6 @@ async def async_setup_entry(
10891088
class NUTSensor(NUTBaseEntity, SensorEntity):
10901089
"""Representation of a sensor entity for NUT status values."""
10911090

1092-
_attr_has_entity_name = True
1093-
1094-
def __init__(
1095-
self,
1096-
coordinator: DataUpdateCoordinator[dict[str, str]],
1097-
sensor_description: SensorEntityDescription,
1098-
data: PyNUTData,
1099-
unique_id: str,
1100-
) -> None:
1101-
"""Initialize the sensor."""
1102-
super().__init__(coordinator, data, unique_id)
1103-
self.entity_description = sensor_description
1104-
self._attr_unique_id = f"{unique_id}_{sensor_description.key}"
1105-
11061091
@property
11071092
def native_value(self) -> str | None:
11081093
"""Return entity state from NUT device."""

homeassistant/components/nut/strings.json

+3
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@
221221
"ups_type": { "name": "UPS type" },
222222
"ups_watchdog_status": { "name": "Watchdog status" },
223223
"watts": { "name": "Watts" }
224+
},
225+
"button": {
226+
"outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" }
224227
}
225228
}
226229
}

tests/components/nut/test_button.py

+102
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Test the NUT button platform."""
2+
3+
import pytest
4+
5+
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
6+
from homeassistant.components.nut.const import INTEGRATION_SUPPORTED_COMMANDS
7+
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.helpers import entity_registry as er
10+
11+
from .util import async_init_integration
12+
13+
14+
@pytest.mark.parametrize(
15+
"model",
16+
[
17+
"CP1350C",
18+
"5E650I",
19+
"5E850I",
20+
"CP1500PFCLCD",
21+
"DL650ELCD",
22+
"EATON5P1550",
23+
"blazer_usb",
24+
],
25+
)
26+
async def test_buttons_ups(
27+
hass: HomeAssistant, entity_registry: er.EntityRegistry, model: str
28+
) -> None:
29+
"""Tests that there are no standard buttons."""
30+
31+
list_commands_return_value = {
32+
supported_command: supported_command
33+
for supported_command in INTEGRATION_SUPPORTED_COMMANDS
34+
}
35+
36+
await async_init_integration(
37+
hass,
38+
model,
39+
list_commands_return_value=list_commands_return_value,
40+
)
41+
42+
button = hass.states.get("button.ups1_power_cycle_outlet_1")
43+
assert not button
44+
45+
46+
@pytest.mark.parametrize(
47+
("model", "unique_id_base"),
48+
[
49+
(
50+
"EATON-EPDU-G3",
51+
"EATON_ePDU MA 00U-C IN: TYPE 00A 0P OUT: 00xTYPE_A000A00000_",
52+
),
53+
],
54+
)
55+
async def test_buttons_pdu_dynamic_outlets(
56+
hass: HomeAssistant,
57+
entity_registry: er.EntityRegistry,
58+
model: str,
59+
unique_id_base: str,
60+
) -> None:
61+
"""Tests that the button entities are correct."""
62+
63+
list_commands_return_value = {
64+
supported_command: supported_command
65+
for supported_command in INTEGRATION_SUPPORTED_COMMANDS
66+
}
67+
68+
for num in range(1, 25):
69+
command = f"outlet.{num!s}.load.cycle"
70+
list_commands_return_value[command] = command
71+
72+
await async_init_integration(
73+
hass,
74+
model,
75+
list_commands_return_value=list_commands_return_value,
76+
)
77+
78+
entity_id = "button.ups1_power_cycle_outlet_a1"
79+
entry = entity_registry.async_get(entity_id)
80+
assert entry
81+
assert entry.unique_id == f"{unique_id_base}outlet.1.load.cycle"
82+
83+
button = hass.states.get(entity_id)
84+
assert button
85+
assert button.state == STATE_UNKNOWN
86+
87+
await hass.services.async_call(
88+
BUTTON_DOMAIN,
89+
SERVICE_PRESS,
90+
{ATTR_ENTITY_ID: entity_id},
91+
blocking=True,
92+
)
93+
await hass.async_block_till_done()
94+
95+
button = hass.states.get(entity_id)
96+
assert button.state != STATE_UNKNOWN
97+
98+
button = hass.states.get("button.ups1_power_cycle_outlet_25")
99+
assert not button
100+
101+
button = hass.states.get("button.ups1_power_cycle_outlet_a25")
102+
assert not button

0 commit comments

Comments
 (0)