Add notify platform to PlayStation Network integration (#149557)

This commit is contained in:
Manu 2025-07-28 16:18:57 +02:00 committed by GitHub
parent b3862591ea
commit 978ee3870c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 395 additions and 12 deletions

View File

@ -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

View File

@ -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:

View File

@ -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("~")
}
)

View File

@ -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),
}

View File

@ -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",
)

View File

@ -51,6 +51,11 @@
"avatar": {
"default": "mdi:account-circle"
}
},
"notify": {
"group_message": {
"default": "mdi:forum"
}
}
}
}

View File

@ -79,6 +79,7 @@ class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity
"""An image entity."""
entity_description: PlaystationNetworkImageEntityDescription
coordinator: PlaystationNetworkUserDataCoordinator
def __init__(
self,

View File

@ -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

View File

@ -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:

View File

@ -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}"
}
}
}
}

View File

@ -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

View File

@ -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**',
}),
}),
})
# ---

View File

@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'notify',
'entity_category': None,
'entity_id': 'notify.testuser_group_publicuniversalfriend',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <PlaystationNetworkNotify.GROUP_MESSAGE: 'group_message'>,
'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': <NotifyEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'notify.testuser_group_publicuniversalfriend',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -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")