Add action for activity reactions to Bring! (#138175)

This commit is contained in:
Manu 2025-07-09 23:08:24 +02:00 committed by GitHub
parent 3307132441
commit 15544769b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 372 additions and 1 deletions

View File

@ -8,20 +8,33 @@ from bring_api import Bring
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .coordinator import ( from .coordinator import (
BringActivityCoordinator, BringActivityCoordinator,
BringConfigEntry, BringConfigEntry,
BringCoordinators, BringCoordinators,
BringDataUpdateCoordinator, BringDataUpdateCoordinator,
) )
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Bring! services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool:
"""Set up Bring! from a config entry.""" """Set up Bring! from a config entry."""

View File

@ -7,5 +7,8 @@ DOMAIN = "bring"
ATTR_SENDER: Final = "sender" ATTR_SENDER: Final = "sender"
ATTR_ITEM_NAME: Final = "item" ATTR_ITEM_NAME: Final = "item"
ATTR_NOTIFICATION_TYPE: Final = "message" ATTR_NOTIFICATION_TYPE: Final = "message"
ATTR_REACTION: Final = "reaction"
ATTR_ACTIVITY: Final = "uuid"
ATTR_RECEIVER: Final = "publicUserUuid"
SERVICE_PUSH_NOTIFICATION = "send_message" SERVICE_PUSH_NOTIFICATION = "send_message"
SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction"

View File

@ -35,6 +35,9 @@
"services": { "services": {
"send_message": { "send_message": {
"service": "mdi:cellphone-message" "service": "mdi:cellphone-message"
},
"send_reaction": {
"service": "mdi:thumb-up"
} }
} }
} }

View File

@ -0,0 +1,110 @@
"""Actions for Bring! integration."""
import logging
from typing import TYPE_CHECKING
from bring_api import (
ActivityType,
BringAuthException,
BringNotificationType,
BringRequestException,
ReactionType,
)
import voluptuous as vol
from homeassistant.components.event import ATTR_EVENT_TYPE
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from .const import (
ATTR_ACTIVITY,
ATTR_REACTION,
ATTR_RECEIVER,
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
)
from .coordinator import BringConfigEntry
_LOGGER = logging.getLogger(__name__)
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
vol.Required(ATTR_REACTION): vol.All(
vol.Upper,
vol.Coerce(ReactionType),
),
}
)
def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry:
"""Return config entry or raise if not found or not loaded."""
entry = hass.config_entries.async_get_entry(entry_id)
if TYPE_CHECKING:
assert entry
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entry_not_loaded",
)
return entry
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for Bring! integration."""
async def async_send_activity_stream_reaction(call: ServiceCall) -> None:
"""Send a reaction in response to recent activity of a list member."""
if (
not (state := hass.states.get(call.data[ATTR_ENTITY_ID]))
or not (entity := er.async_get(hass).async_get(call.data[ATTR_ENTITY_ID]))
or not entity.config_entry_id
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="entity_not_found",
translation_placeholders={
ATTR_ENTITY_ID: call.data[ATTR_ENTITY_ID],
},
)
config_entry = get_config_entry(hass, entity.config_entry_id)
coordinator = config_entry.runtime_data.data
list_uuid = entity.unique_id.split("_")[1]
activity = state.attributes[ATTR_EVENT_TYPE]
reaction: ReactionType = call.data[ATTR_REACTION]
if not activity:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="activity_not_found",
)
try:
await coordinator.bring.notify(
list_uuid,
BringNotificationType.LIST_ACTIVITY_STREAM_REACTION,
receiver=state.attributes[ATTR_RECEIVER],
activity=state.attributes[ATTR_ACTIVITY],
activity_type=ActivityType(activity.upper()),
reaction=reaction,
)
except (BringRequestException, BringAuthException) as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="reaction_request_failed",
) from e
hass.services.async_register(
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
async_send_activity_stream_reaction,
SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA,
)

View File

@ -21,3 +21,28 @@ send_message:
required: false required: false
selector: selector:
text: text:
send_reaction:
fields:
entity_id:
required: true
selector:
entity:
filter:
- integration: bring
domain: event
example: event.shopping_list
reaction:
required: true
selector:
select:
options:
- label: 👍🏼
value: thumbs_up
- label: 🧐
value: monocle
- label: 🤤
value: drooling
- label: ❤️
value: heart
mode: dropdown
example: thumbs_up

View File

@ -144,6 +144,19 @@
}, },
"notify_request_failed": { "notify_request_failed": {
"message": "Failed to send push notification for Bring! due to a connection error, try again later" "message": "Failed to send push notification for Bring! due to a connection error, try again later"
},
"reaction_request_failed": {
"message": "Failed to send reaction for Bring! due to a connection error, try again later"
},
"activity_not_found": {
"message": "Failed to send reaction for Bring! — No recent activity found"
},
"entity_not_found": {
"message": "Failed to send reaction for Bring! — Unknown entity {entity_id}"
},
"entry_not_loaded": {
"message": "The account associated with this Bring! list is either not loaded or disabled in Home Assistant."
} }
}, },
"services": { "services": {
@ -164,6 +177,20 @@
"description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'" "description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'"
} }
} }
},
"send_reaction": {
"name": "Send reaction",
"description": "Sends a reaction to a recent activity on a Bring! list by a member of the shared list.",
"fields": {
"entity_id": {
"name": "Activities",
"description": "Select the Bring! activities event entity for reacting to its most recent event"
},
"reaction": {
"name": "Reaction",
"description": "Type of reaction to send in response."
}
}
} }
}, },
"selector": { "selector": {

View File

@ -0,0 +1,190 @@
"""Test actions of Bring! integration."""
from unittest.mock import AsyncMock
from bring_api import (
ActivityType,
BringActivityResponse,
BringNotificationType,
BringRequestException,
ReactionType,
)
import pytest
from homeassistant.components.bring.const import (
ATTR_REACTION,
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("reaction", "call_arg"),
[
("drooling", ReactionType.DROOLING),
("heart", ReactionType.HEART),
("monocle", ReactionType.MONOCLE),
("thumbs_up", ReactionType.THUMBS_UP),
],
)
async def test_send_reaction(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
mock_bring_client: AsyncMock,
reaction: str,
call_arg: ReactionType,
) -> None:
"""Test send activity stream reaction."""
bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
assert bring_config_entry.state is ConfigEntryState.LOADED
await hass.services.async_call(
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
service_data={
ATTR_ENTITY_ID: "event.einkauf_activities",
ATTR_REACTION: reaction,
},
blocking=True,
)
mock_bring_client.notify.assert_called_once_with(
"e542eef6-dba7-4c31-a52c-29e6ab9d83a5",
BringNotificationType.LIST_ACTIVITY_STREAM_REACTION,
receiver="9a21fdfc-63a4-441a-afc1-ef3030605a9d",
activity="673594a9-f92d-4cb6-adf1-d2f7a83207a4",
activity_type=ActivityType.LIST_ITEMS_CHANGED,
reaction=call_arg,
)
async def test_send_reaction_exception(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
mock_bring_client: AsyncMock,
) -> None:
"""Test send activity stream reaction with exception."""
bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
assert bring_config_entry.state is ConfigEntryState.LOADED
mock_bring_client.notify.side_effect = BringRequestException
with pytest.raises(
HomeAssistantError,
match="Failed to send reaction for Bring! due to a connection error, try again later",
):
await hass.services.async_call(
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
service_data={
ATTR_ENTITY_ID: "event.einkauf_activities",
ATTR_REACTION: "heart",
},
blocking=True,
)
@pytest.mark.usefixtures("mock_bring_client")
async def test_send_reaction_config_entry_not_loaded(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
) -> None:
"""Test send activity stream reaction config entry not loaded exception."""
bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
assert await hass.config_entries.async_unload(bring_config_entry.entry_id)
assert bring_config_entry.state is ConfigEntryState.NOT_LOADED
with pytest.raises(
ServiceValidationError,
match="The account associated with this Bring! list is either not loaded or disabled in Home Assistant",
):
await hass.services.async_call(
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
service_data={
ATTR_ENTITY_ID: "event.einkauf_activities",
ATTR_REACTION: "heart",
},
blocking=True,
)
@pytest.mark.usefixtures("mock_bring_client")
async def test_send_reaction_unknown_entity(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test send activity stream reaction unknown entity exception."""
bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
assert bring_config_entry.state is ConfigEntryState.LOADED
entity_registry.async_update_entity(
"event.einkauf_activities", disabled_by=er.RegistryEntryDisabler.USER
)
with pytest.raises(
ServiceValidationError,
match="Failed to send reaction for Bring! — Unknown entity event.einkauf_activities",
):
await hass.services.async_call(
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
service_data={
ATTR_ENTITY_ID: "event.einkauf_activities",
ATTR_REACTION: "heart",
},
blocking=True,
)
async def test_send_reaction_not_found(
hass: HomeAssistant,
bring_config_entry: MockConfigEntry,
mock_bring_client: AsyncMock,
) -> None:
"""Test send activity stream reaction not found validation error."""
mock_bring_client.get_activity.return_value = BringActivityResponse.from_dict(
{"timeline": [], "timestamp": "2025-01-01T03:09:33.036Z", "totalEvents": 0}
)
bring_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(bring_config_entry.entry_id)
await hass.async_block_till_done()
assert bring_config_entry.state is ConfigEntryState.LOADED
with pytest.raises(
HomeAssistantError,
match="Failed to send reaction for Bring! — No recent activity found",
):
await hass.services.async_call(
DOMAIN,
SERVICE_ACTIVITY_STREAM_REACTION,
service_data={
ATTR_ENTITY_ID: "event.einkauf_activities",
ATTR_REACTION: "heart",
},
blocking=True,
)