From 978ee3870c0631fd17e089de05489c8e20da966a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:18:57 +0200 Subject: [PATCH] Add notify platform to PlayStation Network integration (#149557) --- .../playstation_network/__init__.py | 9 +- .../playstation_network/binary_sensor.py | 7 +- .../playstation_network/coordinator.py | 20 +++ .../playstation_network/diagnostics.py | 10 +- .../components/playstation_network/entity.py | 8 +- .../components/playstation_network/icons.json | 5 + .../components/playstation_network/image.py | 1 + .../components/playstation_network/notify.py | 126 +++++++++++++++++ .../components/playstation_network/sensor.py | 7 +- .../playstation_network/strings.json | 14 ++ .../playstation_network/conftest.py | 11 ++ .../snapshots/test_diagnostics.ambr | 12 +- .../snapshots/test_notify.ambr | 50 +++++++ .../playstation_network/test_notify.py | 127 ++++++++++++++++++ 14 files changed, 395 insertions(+), 12 deletions(-) create mode 100644 homeassistant/components/playstation_network/notify.py create mode 100644 tests/components/playstation_network/snapshots/test_notify.ambr create mode 100644 tests/components/playstation_network/test_notify.py diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index be0eae961e0..bfa9de5d5cb 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from .const import CONF_NPSSO from .coordinator import ( PlaystationNetworkConfigEntry, + PlaystationNetworkGroupsUpdateCoordinator, PlaystationNetworkRuntimeData, PlaystationNetworkTrophyTitlesCoordinator, PlaystationNetworkUserDataCoordinator, @@ -18,6 +19,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.IMAGE, Platform.MEDIA_PLAYER, + Platform.NOTIFY, Platform.SENSOR, ] @@ -34,7 +36,12 @@ async def async_setup_entry( trophy_titles = PlaystationNetworkTrophyTitlesCoordinator(hass, psn, entry) - entry.runtime_data = PlaystationNetworkRuntimeData(coordinator, trophy_titles) + groups = PlaystationNetworkGroupsUpdateCoordinator(hass, psn, entry) + await groups.async_config_entry_first_refresh() + + entry.runtime_data = PlaystationNetworkRuntimeData( + coordinator, trophy_titles, groups + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/playstation_network/binary_sensor.py b/homeassistant/components/playstation_network/binary_sensor.py index 453cfb37347..89a752eff0e 100644 --- a/homeassistant/components/playstation_network/binary_sensor.py +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -13,7 +13,11 @@ from homeassistant.components.binary_sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkData +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) from .entity import PlaystationNetworkServiceEntity PARALLEL_UPDATES = 0 @@ -63,6 +67,7 @@ class PlaystationNetworkBinarySensorEntity( """Representation of a PlayStation Network binary sensor entity.""" entity_description: PlaystationNetworkBinarySensorEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator @property def is_on(self) -> bool: diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index fa00ac2c8ec..19153d1bb01 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -12,6 +12,7 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPClientError, PSNAWPServerError, ) +from psnawp_api.models.group.group_datatypes import GroupDetails from psnawp_api.models.trophies import TrophyTitle from homeassistant.config_entries import ConfigEntry @@ -33,6 +34,7 @@ class PlaystationNetworkRuntimeData: user_data: PlaystationNetworkUserDataCoordinator trophy_titles: PlaystationNetworkTrophyTitlesCoordinator + groups: PlaystationNetworkGroupsUpdateCoordinator class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): @@ -120,3 +122,21 @@ class PlaystationNetworkTrophyTitlesCoordinator( ) await self.config_entry.runtime_data.user_data.async_request_refresh() return self.psn.trophy_titles + + +class PlaystationNetworkGroupsUpdateCoordinator( + PlayStationNetworkBaseCoordinator[dict[str, GroupDetails]] +): + """Groups data update coordinator for PSN.""" + + _update_interval = timedelta(hours=3) + + async def update_data(self) -> dict[str, GroupDetails]: + """Update groups data.""" + return await self.hass.async_add_executor_job( + lambda: { + group_info.group_id: group_info.get_group_information() + for group_info in self.psn.client.get_groups() + if not group_info.group_id.startswith("~") + } + ) diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py index 7b5c762db12..710760a015c 100644 --- a/homeassistant/components/playstation_network/diagnostics.py +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -20,6 +20,11 @@ TO_REDACT = { "onlineId", "url", "username", + "onlineId", + "accountId", + "members", + "body", + "shareable_profile_link", } @@ -28,11 +33,12 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" coordinator = entry.runtime_data.user_data - + groups = entry.runtime_data.groups return { "data": async_redact_data( _serialize_platform_types(asdict(coordinator.data)), TO_REDACT - ) + ), + "groups": async_redact_data(groups.data, TO_REDACT), } diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py index 660c77dc30f..ad7c52bdb39 100644 --- a/homeassistant/components/playstation_network/entity.py +++ b/homeassistant/components/playstation_network/entity.py @@ -7,11 +7,11 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PlaystationNetworkUserDataCoordinator +from .coordinator import PlayStationNetworkBaseCoordinator class PlaystationNetworkServiceEntity( - CoordinatorEntity[PlaystationNetworkUserDataCoordinator] + CoordinatorEntity[PlayStationNetworkBaseCoordinator] ): """Common entity class for PlayStationNetwork Service entities.""" @@ -19,7 +19,7 @@ class PlaystationNetworkServiceEntity( def __init__( self, - coordinator: PlaystationNetworkUserDataCoordinator, + coordinator: PlayStationNetworkBaseCoordinator, entity_description: EntityDescription, ) -> None: """Initialize PlayStation Network Service Entity.""" @@ -32,7 +32,7 @@ class PlaystationNetworkServiceEntity( ) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, - name=coordinator.data.username, + name=coordinator.psn.user.online_id, entry_type=DeviceEntryType.SERVICE, manufacturer="Sony Interactive Entertainment", ) diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 2ea09823ca4..af2236bd126 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -51,6 +51,11 @@ "avatar": { "default": "mdi:account-circle" } + }, + "notify": { + "group_message": { + "default": "mdi:forum" + } } } } diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py index 8f9d19e3a55..b0195002c66 100644 --- a/homeassistant/components/playstation_network/image.py +++ b/homeassistant/components/playstation_network/image.py @@ -79,6 +79,7 @@ class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity """An image entity.""" entity_description: PlaystationNetworkImageEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator def __init__( self, diff --git a/homeassistant/components/playstation_network/notify.py b/homeassistant/components/playstation_network/notify.py new file mode 100644 index 00000000000..872ad98a594 --- /dev/null +++ b/homeassistant/components/playstation_network/notify.py @@ -0,0 +1,126 @@ +"""Notify platform for PlayStation Network.""" + +from __future__ import annotations + +from enum import StrEnum + +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) + +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + NotifyEntity, + NotifyEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkGroupsUpdateCoordinator, +) +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 20 + + +class PlaystationNetworkNotify(StrEnum): + """PlayStation Network sensors.""" + + GROUP_MESSAGE = "group_message" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notify entity platform.""" + + coordinator = config_entry.runtime_data.groups + groups_added: set[str] = set() + entity_registry = er.async_get(hass) + + @callback + def add_entities() -> None: + nonlocal groups_added + + new_groups = set(coordinator.data.keys()) - groups_added + if new_groups: + async_add_entities( + PlaystationNetworkNotifyEntity(coordinator, group_id) + for group_id in new_groups + ) + groups_added |= new_groups + + deleted_groups = groups_added - set(coordinator.data.keys()) + for group_id in deleted_groups: + if entity_id := entity_registry.async_get_entity_id( + NOTIFY_DOMAIN, + DOMAIN, + f"{coordinator.config_entry.unique_id}_{group_id}", + ): + entity_registry.async_remove(entity_id) + + coordinator.async_add_listener(add_entities) + add_entities() + + +class PlaystationNetworkNotifyEntity(PlaystationNetworkServiceEntity, NotifyEntity): + """Representation of a PlayStation Network notify entity.""" + + coordinator: PlaystationNetworkGroupsUpdateCoordinator + + def __init__( + self, + coordinator: PlaystationNetworkGroupsUpdateCoordinator, + group_id: str, + ) -> None: + """Initialize a notification entity.""" + self.group = coordinator.psn.psn.group(group_id=group_id) + group_details = coordinator.data[group_id] + self.entity_description = NotifyEntityDescription( + key=group_id, + translation_key=PlaystationNetworkNotify.GROUP_MESSAGE, + translation_placeholders={ + "group_name": group_details["groupName"]["value"] + or ", ".join( + member["onlineId"] + for member in group_details["members"] + if member["accountId"] != coordinator.psn.user.account_id + ) + }, + ) + + super().__init__(coordinator, self.entity_description) + + def send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + + try: + self.group.send_message(message) + except PSNAWPNotFoundError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="group_invalid", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except PSNAWPForbiddenError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_forbidden", + translation_placeholders=dict(self.translation_placeholders), + ) from e + except (PSNAWPServerError, PSNAWPClientError) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_failed", + translation_placeholders=dict(self.translation_placeholders), + ) from e diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index b17b4c04ab7..63cca074c3e 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -18,7 +18,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkData +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) from .entity import PlaystationNetworkServiceEntity PARALLEL_UPDATES = 0 @@ -145,6 +149,7 @@ class PlaystationNetworkSensorEntity( """Representation of a PlayStation Network sensor entity.""" entity_description: PlaystationNetworkSensorEntityDescription + coordinator: PlaystationNetworkUserDataCoordinator @property def native_value(self) -> StateType | datetime: diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index aaefdf51506..4fefc508ea2 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -50,6 +50,15 @@ }, "update_failed": { "message": "Data retrieval failed when trying to access the PlayStation Network." + }, + "group_invalid": { + "message": "Failed to send message to group {group_name}. The group is invalid or does not exist." + }, + "send_message_forbidden": { + "message": "Failed to send message to group {group_name}. You are not allowed to send messages to this group." + }, + "send_message_failed": { + "message": "Failed to send message to group {group_name}. Try again later." } }, "entity": { @@ -104,6 +113,11 @@ "avatar": { "name": "Avatar" } + }, + "notify": { + "group_message": { + "name": "Group: {group_name}" + } } } } diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 77ec2377932..8480d7ecf5d 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch +from psnawp_api.models.group.group import Group from psnawp_api.models.trophies import ( PlatformType, TrophySet, @@ -159,6 +160,16 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: client.me.return_value.get_shareable_profile_link.return_value = { "shareImageUrl": "https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493" } + group = MagicMock(spec=Group, group_id="test-groupid") + + group.get_group_information.return_value = { + "groupName": {"value": ""}, + "members": [ + {"onlineId": "PublicUniversalFriend", "accountId": "fren-psn-id"}, + {"onlineId": "testuser", "accountId": PSN_ID}, + ], + } + client.me.return_value.get_groups.return_value = [group] yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index 0b7aa63fc03..894fa2d9084 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -71,9 +71,7 @@ 'PS5', 'PSVITA', ]), - 'shareable_profile_link': dict({ - 'shareImageUrl': 'https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493', - }), + 'shareable_profile_link': '**REDACTED**', 'trophy_summary': dict({ 'account_id': '**REDACTED**', 'earned_trophies': dict({ @@ -88,5 +86,13 @@ }), 'username': '**REDACTED**', }), + 'groups': dict({ + 'test-groupid': dict({ + 'groupName': dict({ + 'value': '', + }), + 'members': '**REDACTED**', + }), + }), }) # --- diff --git a/tests/components/playstation_network/snapshots/test_notify.ambr b/tests/components/playstation_network/snapshots/test_notify.ambr new file mode 100644 index 00000000000..60525925787 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_notify.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_notify_platform[notify.testuser_group_publicuniversalfriend-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.testuser_group_publicuniversalfriend', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Group: PublicUniversalFriend', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_test-groupid', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.testuser_group_publicuniversalfriend-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Group: PublicUniversalFriend', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.testuser_group_publicuniversalfriend', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/playstation_network/test_notify.py b/tests/components/playstation_network/test_notify.py new file mode 100644 index 00000000000..ebaac37a09f --- /dev/null +++ b/tests/components/playstation_network/test_notify.py @@ -0,0 +1,127 @@ +"""Tests for the PlayStation Network notify platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import MagicMock, patch + +from freezegun.api import freeze_time +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPClientError, + PSNAWPForbiddenError, + PSNAWPNotFoundError, + PSNAWPServerError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.notify import ( + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the notify platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@freeze_time("2025-07-28T00:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test send message.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.testuser_group_publicuniversalfriend", + ATTR_MESSAGE: "henlo fren", + }, + blocking=True, + ) + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == "2025-07-28T00:00:00+00:00" + mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren") + + +@pytest.mark.parametrize( + "exception", + [PSNAWPClientError, PSNAWPForbiddenError, PSNAWPNotFoundError, PSNAWPServerError], +) +async def test_send_message_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test send message exceptions.""" + + mock_psnawpapi.group.return_value.send_message.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get("notify.testuser_group_publicuniversalfriend") + assert state + assert state.state == STATE_UNKNOWN + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.testuser_group_publicuniversalfriend", + ATTR_MESSAGE: "henlo fren", + }, + blocking=True, + ) + + mock_psnawpapi.group.return_value.send_message.assert_called_once_with("henlo fren")