From a00b50c19500613b461f72b7f43379df9e25dcd1 Mon Sep 17 00:00:00 2001 From: hanwg Date: Wed, 3 Dec 2025 16:44:01 +0800 Subject: [PATCH] Fix bug in group notify entities when title is missing (#157171) Co-authored-by: Martin Hjelmare --- homeassistant/components/group/notify.py | 33 ++++- tests/components/group/test_notify.py | 175 +++++++++++++++++++++-- 2 files changed, 189 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index e710485c46f..096305c7689 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -18,10 +18,12 @@ from homeassistant.components.notify import ( SERVICE_SEND_MESSAGE, BaseNotificationService, NotifyEntity, + NotifyEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, CONF_ACTION, CONF_ENTITIES, CONF_SERVICE, @@ -173,14 +175,23 @@ class NotifyGroup(GroupEntity, NotifyEntity): async def async_send_message(self, message: str, title: str | None = None) -> None: """Send a message to all members of the group.""" + + data = { + ATTR_MESSAGE: message, + ATTR_ENTITY_ID: self._entity_ids, + } + + # add title only if supported and provided + if ( + title is not None + and self._attr_supported_features & NotifyEntityFeature.TITLE + ): + data[ATTR_TITLE] = title + await self.hass.services.async_call( NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, - { - ATTR_MESSAGE: message, - ATTR_TITLE: title, - ATTR_ENTITY_ID: self._entity_ids, - }, + data, blocking=True, context=self._context, ) @@ -194,3 +205,15 @@ class NotifyGroup(GroupEntity, NotifyEntity): for entity_id in self._entity_ids if (state := self.hass.states.get(entity_id)) is not None ) + + # Support title if all members support it + self._attr_supported_features |= NotifyEntityFeature.TITLE + for entity_id in self._entity_ids: + state = self.hass.states.get(entity_id) + if ( + state is None + or not state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + & NotifyEntityFeature.TITLE + ): + self._attr_supported_features &= ~NotifyEntityFeature.TITLE + break diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index 49ad71f5b6b..aece5976eef 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -16,6 +16,7 @@ from homeassistant.components.notify import ( DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, NotifyEntity, + NotifyEntityFeature, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( @@ -298,9 +299,11 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: class MockNotifyEntity(MockEntity, NotifyEntity): """Mock Email notifier entity to use in tests.""" - def __init__(self, **values: Any) -> None: + def __init__(self, *, is_title_supported: bool, **values: Any) -> None: """Initialize the mock entity.""" super().__init__(**values) + if is_title_supported: + self._attr_supported_features = NotifyEntityFeature.TITLE self.send_message_mock_calls = MagicMock() async def async_send_message(self, message: str, title: str | None = None) -> None: @@ -330,11 +333,21 @@ async def help_async_unload_entry( @pytest.fixture async def mock_notifiers( hass: HomeAssistant, config_flow_fixture: None -) -> list[NotifyEntity]: +) -> list[MockNotifyEntity]: """Set up the notify entities.""" - entity = MockNotifyEntity(name="test", entity_id="notify.test") - entity2 = MockNotifyEntity(name="test2", entity_id="notify.test2") - entities = [entity, entity2] + entity_title_1 = MockNotifyEntity( + is_title_supported=True, name="has_title_1", entity_id="notify.has_title_1" + ) + entity_title_2 = MockNotifyEntity( + is_title_supported=True, name="has_title_2", entity_id="notify.has_title_2" + ) + entity_no_title_1 = MockNotifyEntity( + is_title_supported=False, name="no_title_1", entity_id="notify.no_title_1" + ) + entity_no_title_2 = MockNotifyEntity( + is_title_supported=False, name="no_title_2", entity_id="notify.no_title_2" + ) + entities = [entity_title_1, entity_title_2, entity_no_title_1, entity_no_title_2] test_entry = MockConfigEntry(domain="test") test_entry.add_to_hass(hass) mock_integration( @@ -352,19 +365,23 @@ async def mock_notifiers( async def test_notify_entity_group( - hass: HomeAssistant, mock_notifiers: list[NotifyEntity] + hass: HomeAssistant, mock_notifiers: list[MockNotifyEntity] ) -> None: """Test sending a message to a notify group.""" - entity, entity2 = mock_notifiers - assert entity.send_message_mock_calls.call_count == 0 - assert entity2.send_message_mock_calls.call_count == 0 + entity_title_1, entity_title_2, entity_no_title_1, entity_no_title_2 = ( + mock_notifiers + ) + for mock_notifier in mock_notifiers: + assert mock_notifier.send_message_mock_calls.call_count == 0 + + # test group containing 1 member with title supported config_entry = MockConfigEntry( domain=DOMAIN, options={ "group_type": "notify", "name": "Test Group", - "entities": ["notify.test", "notify.test2"], + "entities": ["notify.has_title_1"], "hide_members": True, }, title="Test Group", @@ -384,14 +401,144 @@ async def test_notify_entity_group( blocking=True, ) - assert entity.send_message_mock_calls.call_count == 1 - assert entity.send_message_mock_calls.call_args == call( + assert entity_title_1.send_message_mock_calls.call_count == 1 + assert entity_title_1.send_message_mock_calls.call_args == call( "Hello", title="Test notification" ) - assert entity2.send_message_mock_calls.call_count == 1 - assert entity2.send_message_mock_calls.call_args == call( + + for mock_notifier in mock_notifiers: + mock_notifier.send_message_mock_calls.reset_mock() + + # test group containing 1 member with title supported but no title provided + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_MESSAGE: "Hello", + ATTR_ENTITY_ID: "notify.test_group", + }, + blocking=True, + ) + + assert entity_title_1.send_message_mock_calls.call_count == 1 + assert entity_title_1.send_message_mock_calls.call_args == call("Hello", title=None) + + for mock_notifier in mock_notifiers: + mock_notifier.send_message_mock_calls.reset_mock() + + # test group containing 2 members with title supported + + config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "group_type": "notify", + "name": "Test Group 2", + "entities": ["notify.has_title_1", "notify.has_title_2"], + "hide_members": True, + }, + title="Test Group 2", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_MESSAGE: "Hello", + ATTR_TITLE: "Test notification", + ATTR_ENTITY_ID: "notify.test_group_2", + }, + blocking=True, + ) + + assert entity_title_1.send_message_mock_calls.call_count == 1 + assert entity_title_1.send_message_mock_calls.call_args == call( "Hello", title="Test notification" ) + assert entity_title_2.send_message_mock_calls.call_count == 1 + assert entity_title_2.send_message_mock_calls.call_args == call( + "Hello", title="Test notification" + ) + + for mock_notifier in mock_notifiers: + mock_notifier.send_message_mock_calls.reset_mock() + + # test group containing 2 members: 1 title supported and 1 not supported + # title is not supported since not all members support it + + config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "group_type": "notify", + "name": "Test Group", + "entities": ["notify.has_title_1", "notify.no_title_1"], + "hide_members": True, + }, + title="Test Group 3", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_MESSAGE: "Hello", + ATTR_TITLE: "Test notification", + ATTR_ENTITY_ID: "notify.test_group_3", + }, + blocking=True, + ) + + assert entity_title_1.send_message_mock_calls.call_count == 1 + assert entity_title_1.send_message_mock_calls.call_args == call("Hello", title=None) + assert entity_no_title_1.send_message_mock_calls.call_count == 1 + assert entity_no_title_1.send_message_mock_calls.call_args == call( + "Hello", title=None + ) + + for mock_notifier in mock_notifiers: + mock_notifier.send_message_mock_calls.reset_mock() + + # test group containing 2 members: both not supporting title + + config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "group_type": "notify", + "name": "Test Group", + "entities": ["notify.no_title_1", "notify.no_title_2"], + "hide_members": True, + }, + title="Test Group 4", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_MESSAGE: "Hello", + ATTR_TITLE: "Test notification", + ATTR_ENTITY_ID: "notify.test_group_4", + }, + blocking=True, + ) + + assert entity_no_title_1.send_message_mock_calls.call_count == 1 + assert entity_no_title_1.send_message_mock_calls.call_args == call( + "Hello", title=None + ) + assert entity_no_title_2.send_message_mock_calls.call_count == 1 + assert entity_no_title_2.send_message_mock_calls.call_args == call( + "Hello", title=None + ) async def test_state_reporting(hass: HomeAssistant) -> None: