Skip to content

Commit 64381ac

Browse files
authored
Mark device actions from hidden or auxiliary entities as secondary (home-assistant#70278)
1 parent 2a99084 commit 64381ac

File tree

24 files changed

+862
-151
lines changed

24 files changed

+862
-151
lines changed

homeassistant/components/alarm_control_panel/device_action.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ async def async_get_actions(
6767

6868
supported_features = get_supported_features(hass, entry.entity_id)
6969

70-
base_action = {
70+
base_action: dict = {
7171
CONF_DEVICE_ID: device_id,
7272
CONF_DOMAIN: DOMAIN,
7373
CONF_ENTITY_ID: entry.entity_id,

homeassistant/components/device_automation/__init__.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,13 @@
1313
import voluptuous_serialize
1414

1515
from homeassistant.components import websocket_api
16-
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM
17-
from homeassistant.core import HomeAssistant
16+
from homeassistant.const import (
17+
ATTR_ENTITY_ID,
18+
CONF_DEVICE_ID,
19+
CONF_DOMAIN,
20+
CONF_PLATFORM,
21+
)
22+
from homeassistant.core import HomeAssistant, callback
1823
from homeassistant.helpers import (
1924
config_validation as cv,
2025
device_registry as dr,
@@ -166,6 +171,24 @@ async def async_get_device_automation_platform(
166171
return platform
167172

168173

174+
@callback
175+
def _async_set_entity_device_automation_metadata(
176+
hass: HomeAssistant, automation: dict[str, Any]
177+
) -> None:
178+
"""Set device automation metadata based on entity registry entry data."""
179+
if "metadata" not in automation:
180+
automation["metadata"] = {}
181+
if ATTR_ENTITY_ID not in automation or "secondary" in automation["metadata"]:
182+
return
183+
184+
entity_registry = er.async_get(hass)
185+
# Guard against the entry being removed before this is called
186+
if not (entry := entity_registry.async_get(automation[ATTR_ENTITY_ID])):
187+
return
188+
189+
automation["metadata"]["secondary"] = bool(entry.entity_category or entry.hidden_by)
190+
191+
169192
async def _async_get_device_automations_from_domain(
170193
hass, domain, automation_type, device_ids, return_exceptions
171194
):
@@ -242,6 +265,8 @@ async def async_get_device_automations(
242265
)
243266
continue
244267
for automation in device_results:
268+
if automation_type == DeviceAutomationType.ACTION:
269+
_async_set_entity_device_automation_metadata(hass, automation)
245270
combined_results[automation["device_id"]].append(automation)
246271

247272
return combined_results

homeassistant/helpers/config_validation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,7 @@ def _base_trigger_validator(value: Any) -> Any:
14651465
**SCRIPT_ACTION_BASE_SCHEMA,
14661466
vol.Required(CONF_DEVICE_ID): string,
14671467
vol.Required(CONF_DOMAIN): str,
1468+
vol.Remove("metadata"): dict,
14681469
}
14691470
)
14701471

script/scaffold/templates/device_action/tests/test_device_action.py

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from homeassistant.components.device_automation import DeviceAutomationType
77
from homeassistant.core import HomeAssistant
88
from homeassistant.helpers import device_registry, entity_registry
9+
from homeassistant.helpers.entity import EntityCategory
910
from homeassistant.setup import async_setup_component
1011

1112
from tests.common import (
@@ -46,16 +47,58 @@ async def test_get_actions(
4647
expected_actions = [
4748
{
4849
"domain": DOMAIN,
49-
"type": "turn_on",
50+
"type": action,
5051
"device_id": device_entry.id,
51-
"entity_id": "NEW_DOMAIN.test_5678",
52-
},
52+
"entity_id": f"{DOMAIN}.test_5678",
53+
}
54+
for action in ["turn_off", "turn_on"]
55+
]
56+
actions = await async_get_device_automations(
57+
hass, DeviceAutomationType.ACTION, device_entry.id
58+
)
59+
assert_lists_same(actions, expected_actions)
60+
61+
62+
@pytest.mark.parametrize(
63+
"hidden_by,entity_category",
64+
(
65+
(entity_registry.RegistryEntryHider.INTEGRATION, None),
66+
(entity_registry.RegistryEntryHider.USER, None),
67+
(None, EntityCategory.CONFIG),
68+
(None, EntityCategory.DIAGNOSTIC),
69+
),
70+
)
71+
async def test_get_actions_hidden_auxiliary(
72+
hass,
73+
device_reg,
74+
entity_reg,
75+
hidden_by,
76+
entity_category,
77+
):
78+
"""Test we get the expected actions from a hidden or auxiliary entity."""
79+
config_entry = MockConfigEntry(domain="test", data={})
80+
config_entry.add_to_hass(hass)
81+
device_entry = device_reg.async_get_or_create(
82+
config_entry_id=config_entry.entry_id,
83+
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
84+
)
85+
entity_reg.async_get_or_create(
86+
DOMAIN,
87+
"test",
88+
"5678",
89+
device_id=device_entry.id,
90+
entity_category=entity_category,
91+
hidden_by=hidden_by,
92+
)
93+
expected_actions = [
5394
{
5495
"domain": DOMAIN,
55-
"type": "turn_off",
96+
"type": action,
5697
"device_id": device_entry.id,
57-
"entity_id": "NEW_DOMAIN.test_5678",
58-
},
98+
"entity_id": f"{DOMAIN}.test_5678",
99+
"metadata": {"secondary": True},
100+
}
101+
for action in ["turn_off", "turn_on", "toggle"]
59102
]
60103
actions = await async_get_device_automations(
61104
hass, DeviceAutomationType.ACTION, device_entry.id

tests/common.py

Lines changed: 9 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from __future__ import annotations
33

44
import asyncio
5-
import collections
65
from collections import OrderedDict
76
from collections.abc import Awaitable, Collection
87
from contextlib import contextmanager
@@ -1226,72 +1225,17 @@ def mock_signal_handler(*args):
12261225
return calls
12271226

12281227

1229-
class hashdict(dict):
1230-
"""
1231-
hashable dict implementation, suitable for use as a key into other dicts.
1232-
1233-
>>> h1 = hashdict({"apples": 1, "bananas":2})
1234-
>>> h2 = hashdict({"bananas": 3, "mangoes": 5})
1235-
>>> h1+h2
1236-
hashdict(apples=1, bananas=3, mangoes=5)
1237-
>>> d1 = {}
1238-
>>> d1[h1] = "salad"
1239-
>>> d1[h1]
1240-
'salad'
1241-
>>> d1[h2]
1242-
Traceback (most recent call last):
1243-
...
1244-
KeyError: hashdict(bananas=3, mangoes=5)
1245-
1246-
based on answers from
1247-
http://stackoverflow.com/questions/1151658/python-hashable-dicts
1228+
def assert_lists_same(a, b):
1229+
"""Compare two lists, ignoring order.
12481230
1231+
Check both that all items in a are in b and that all items in b are in a,
1232+
otherwise assert_lists_same(["1", "1"], ["1", "2"]) could be True.
12491233
"""
1250-
1251-
def __key(self):
1252-
return tuple(sorted(self.items()))
1253-
1254-
def __repr__(self): # noqa: D105 no docstring
1255-
return ", ".join(f"{i[0]!s}={i[1]!r}" for i in self.__key())
1256-
1257-
def __hash__(self): # noqa: D105 no docstring
1258-
return hash(self.__key())
1259-
1260-
def __setitem__(self, key, value): # noqa: D105 no docstring
1261-
raise TypeError(f"{self.__class__.__name__} does not support item assignment")
1262-
1263-
def __delitem__(self, key): # noqa: D105 no docstring
1264-
raise TypeError(f"{self.__class__.__name__} does not support item assignment")
1265-
1266-
def clear(self): # noqa: D102 no docstring
1267-
raise TypeError(f"{self.__class__.__name__} does not support item assignment")
1268-
1269-
def pop(self, *args, **kwargs): # noqa: D102 no docstring
1270-
raise TypeError(f"{self.__class__.__name__} does not support item assignment")
1271-
1272-
def popitem(self, *args, **kwargs): # noqa: D102 no docstring
1273-
raise TypeError(f"{self.__class__.__name__} does not support item assignment")
1274-
1275-
def setdefault(self, *args, **kwargs): # noqa: D102 no docstring
1276-
raise TypeError(f"{self.__class__.__name__} does not support item assignment")
1277-
1278-
def update(self, *args, **kwargs): # noqa: D102 no docstring
1279-
raise TypeError(f"{self.__class__.__name__} does not support item assignment")
1280-
1281-
# update is not ok because it mutates the object
1282-
# __add__ is ok because it creates a new object
1283-
# while the new object is under construction, it's ok to mutate it
1284-
def __add__(self, right): # noqa: D105 no docstring
1285-
result = hashdict(self)
1286-
dict.update(result, right)
1287-
return result
1288-
1289-
1290-
def assert_lists_same(a, b):
1291-
"""Compare two lists, ignoring order."""
1292-
assert collections.Counter([hashdict(i) for i in a]) == collections.Counter(
1293-
[hashdict(i) for i in b]
1294-
)
1234+
assert len(a) == len(b)
1235+
for i in a:
1236+
assert i in b
1237+
for i in b:
1238+
assert i in a
12951239

12961240

12971241
def raise_contains_mocks(val):

tests/components/alarm_control_panel/test_device_action.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
STATE_UNKNOWN,
1616
)
1717
from homeassistant.helpers import device_registry
18+
from homeassistant.helpers.entity import EntityCategory
19+
from homeassistant.helpers.entity_registry import RegistryEntryHider
1820
from homeassistant.setup import async_setup_component
1921

2022
from tests.common import (
@@ -125,6 +127,7 @@ async def test_get_actions(
125127
"type": action,
126128
"device_id": device_entry.id,
127129
"entity_id": f"{DOMAIN}.test_5678",
130+
"metadata": {"secondary": False},
128131
}
129132
for action in expected_action_types
130133
]
@@ -134,6 +137,55 @@ async def test_get_actions(
134137
assert_lists_same(actions, expected_actions)
135138

136139

140+
@pytest.mark.parametrize(
141+
"hidden_by,entity_category",
142+
(
143+
(RegistryEntryHider.INTEGRATION, None),
144+
(RegistryEntryHider.USER, None),
145+
(None, EntityCategory.CONFIG),
146+
(None, EntityCategory.DIAGNOSTIC),
147+
),
148+
)
149+
async def test_get_actions_hidden_auxiliary(
150+
hass,
151+
device_reg,
152+
entity_reg,
153+
hidden_by,
154+
entity_category,
155+
):
156+
"""Test we get the expected actions from a hidden or auxiliary entity."""
157+
config_entry = MockConfigEntry(domain="test", data={})
158+
config_entry.add_to_hass(hass)
159+
device_entry = device_reg.async_get_or_create(
160+
config_entry_id=config_entry.entry_id,
161+
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
162+
)
163+
entity_reg.async_get_or_create(
164+
DOMAIN,
165+
"test",
166+
"5678",
167+
device_id=device_entry.id,
168+
entity_category=entity_category,
169+
hidden_by=hidden_by,
170+
supported_features=const.AlarmControlPanelEntityFeature.ARM_AWAY,
171+
)
172+
expected_actions = []
173+
expected_actions += [
174+
{
175+
"domain": DOMAIN,
176+
"type": action,
177+
"device_id": device_entry.id,
178+
"entity_id": f"{DOMAIN}.test_5678",
179+
"metadata": {"secondary": True},
180+
}
181+
for action in ["disarm", "arm_away"]
182+
]
183+
actions = await async_get_device_automations(
184+
hass, DeviceAutomationType.ACTION, device_entry.id
185+
)
186+
assert_lists_same(actions, expected_actions)
187+
188+
137189
async def test_get_actions_arm_night_only(hass, device_reg, entity_reg):
138190
"""Test we get the expected actions from a alarm_control_panel."""
139191
config_entry = MockConfigEntry(domain="test", data={})
@@ -152,12 +204,14 @@ async def test_get_actions_arm_night_only(hass, device_reg, entity_reg):
152204
"type": "arm_night",
153205
"device_id": device_entry.id,
154206
"entity_id": "alarm_control_panel.test_5678",
207+
"metadata": {"secondary": False},
155208
},
156209
{
157210
"domain": DOMAIN,
158211
"type": "disarm",
159212
"device_id": device_entry.id,
160213
"entity_id": "alarm_control_panel.test_5678",
214+
"metadata": {"secondary": False},
161215
},
162216
]
163217
actions = await async_get_device_automations(

tests/components/button/test_device_action.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from homeassistant.components.device_automation import DeviceAutomationType
77
from homeassistant.core import HomeAssistant
88
from homeassistant.helpers import device_registry, entity_registry
9+
from homeassistant.helpers.entity import EntityCategory
910
from homeassistant.setup import async_setup_component
1011

1112
from tests.common import (
@@ -49,6 +50,7 @@ async def test_get_actions(
4950
"type": "press",
5051
"device_id": device_entry.id,
5152
"entity_id": "button.test_5678",
53+
"metadata": {"secondary": False},
5254
}
5355
]
5456
actions = await async_get_device_automations(
@@ -57,6 +59,54 @@ async def test_get_actions(
5759
assert_lists_same(actions, expected_actions)
5860

5961

62+
@pytest.mark.parametrize(
63+
"hidden_by,entity_category",
64+
(
65+
(entity_registry.RegistryEntryHider.INTEGRATION, None),
66+
(entity_registry.RegistryEntryHider.USER, None),
67+
(None, EntityCategory.CONFIG),
68+
(None, EntityCategory.DIAGNOSTIC),
69+
),
70+
)
71+
async def test_get_actions_hidden_auxiliary(
72+
hass,
73+
device_reg,
74+
entity_reg,
75+
hidden_by,
76+
entity_category,
77+
):
78+
"""Test we get the expected actions from a hidden or auxiliary entity."""
79+
config_entry = MockConfigEntry(domain="test", data={})
80+
config_entry.add_to_hass(hass)
81+
device_entry = device_reg.async_get_or_create(
82+
config_entry_id=config_entry.entry_id,
83+
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
84+
)
85+
entity_reg.async_get_or_create(
86+
DOMAIN,
87+
"test",
88+
"5678",
89+
device_id=device_entry.id,
90+
entity_category=entity_category,
91+
hidden_by=hidden_by,
92+
)
93+
expected_actions = []
94+
expected_actions += [
95+
{
96+
"domain": DOMAIN,
97+
"type": action,
98+
"device_id": device_entry.id,
99+
"entity_id": f"{DOMAIN}.test_5678",
100+
"metadata": {"secondary": True},
101+
}
102+
for action in ["press"]
103+
]
104+
actions = await async_get_device_automations(
105+
hass, DeviceAutomationType.ACTION, device_entry.id
106+
)
107+
assert_lists_same(actions, expected_actions)
108+
109+
60110
async def test_action(hass: HomeAssistant) -> None:
61111
"""Test for press action."""
62112
assert await async_setup_component(

0 commit comments

Comments
 (0)