Add PlayStation Network Integration (#133901)

* clean pull request

* Create one device per console

* Requested changes

* Pr/tr4nt0r/1 (#2)

* clean pull request

* Create one device per console

* device setup

* Merge PR1 - Dynamic Device Support

* Merge PR1 - Dynamic Device Support

---------

Co-authored-by: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com>

* nitpicks

* Update config_flow test

* Update quality_scale.yaml

* repair integrations.json

* minor updates

* Add translation string for invalid account

* misc changes post review

* Minor strings updates

* strengthen config_flow test

* Requested changes

* Applied patch to commit a358725

* migrate PlayStationNetwork helper classes to HA

* Revert to standard psn library

* Updates to media_player logic

* add default_factory, change registered_platforms to set

* Improve test coverage

* Add snapshot test for media_player platform

* fix token parse error

* Parametrize media player test

* Add PS3 support

* Add PS3 support

* Add concurrent console support

* Adjust psnawp rate limit

* Convert to package PlatformType

* Update dependency to PSNAWP==3.0.0

* small improvements

* Add PlayStation PC Support

* Refactor active sessions list

* shift async logic to helper

* Implemented suggested changes

* Suggested changes

* Updated tests

* Suggested changes

* Fix test

* Suggested changes

* Suggested changes

* Update config_flow tests

* Group remaining api call in single executor

---------

Co-authored-by: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Jack Powell
2025-06-23 17:46:06 -04:00
committed by GitHub
parent 646ddf9c2d
commit c671ff3cf1
21 changed files with 1317 additions and 1 deletions

View File

@@ -0,0 +1 @@
"""Tests for the Playstation Network integration."""

View File

@@ -0,0 +1,113 @@
"""Common fixtures for the Playstation Network tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN
from tests.common import MockConfigEntry
NPSSO_TOKEN: str = "npsso-token"
NPSSO_TOKEN_INVALID_JSON: str = "{'npsso': 'npsso-token'"
PSN_ID: str = "my-psn-id"
@pytest.fixture(name="config_entry")
def mock_config_entry() -> MockConfigEntry:
"""Mock PlayStation Network configuration entry."""
return MockConfigEntry(
domain=DOMAIN,
title="test-user",
data={
CONF_NPSSO: NPSSO_TOKEN,
},
unique_id=PSN_ID,
)
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.playstation_network.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_user() -> Generator[MagicMock]:
"""Mock psnawp_api User object."""
with patch(
"homeassistant.components.playstation_network.helpers.User",
autospec=True,
) as mock_client:
client = mock_client.return_value
client.account_id = PSN_ID
client.online_id = "testuser"
client.get_presence.return_value = {
"basicPresence": {
"availability": "availableToPlay",
"primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"},
"gameTitleInfoList": [
{
"npTitleId": "PPSA07784_00",
"titleName": "STAR WARS Jedi: Survivor™",
"format": "PS5",
"launchPlatform": "PS5",
"conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png",
}
],
}
}
yield client
@pytest.fixture
def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]:
"""Mock psnawp_api."""
with patch(
"homeassistant.components.playstation_network.helpers.PSNAWP",
autospec=True,
) as mock_client:
client = mock_client.return_value
client.user.return_value = mock_user
client.me.return_value.get_account_devices.return_value = [
{
"deviceId": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234",
"deviceType": "PS5",
"activationType": "PRIMARY",
"activationDate": "2021-01-14T18:00:00.000Z",
"accountDeviceVector": "abcdefghijklmnopqrstuv",
}
]
yield client
@pytest.fixture
def mock_psnawp_npsso(mock_user: MagicMock) -> Generator[MagicMock]:
"""Mock psnawp_api."""
with patch(
"psnawp_api.utils.misc.parse_npsso_token",
autospec=True,
) as mock_parse_npsso_token:
npsso = mock_parse_npsso_token.return_value
npsso.parse_npsso_token.return_value = NPSSO_TOKEN
yield npsso
@pytest.fixture
def mock_token() -> Generator[MagicMock]:
"""Mock token generator."""
with patch("secrets.token_hex", return_value="123456789") as token:
yield token

View File

@@ -0,0 +1,321 @@
# serializer version: 1
# name: test_platform[PS4_idle][media_player.playstation_4-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.playstation_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': None,
'platform': 'playstation_network',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'playstation',
'unique_id': 'my-psn-id_PS4',
'unit_of_measurement': None,
})
# ---
# name: test_platform[PS4_idle][media_player.playstation_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'entity_picture_local': None,
'friendly_name': 'PlayStation 4',
'media_content_type': <MediaType.GAME: 'game'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'media_player.playstation_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_platform[PS4_offline][media_player.playstation_4-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.playstation_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': None,
'platform': 'playstation_network',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'playstation',
'unique_id': 'my-psn-id_PS4',
'unit_of_measurement': None,
})
# ---
# name: test_platform[PS4_offline][media_player.playstation_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'PlayStation 4',
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'media_player.playstation_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform[PS4_playing][media_player.playstation_4-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.playstation_4',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': None,
'platform': 'playstation_network',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'playstation',
'unique_id': 'my-psn-id_PS4',
'unit_of_measurement': None,
})
# ---
# name: test_platform[PS4_playing][media_player.playstation_4-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'entity_picture': 'http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png',
'entity_picture_local': '/api/media_player_proxy/media_player.playstation_4?token=123456789&cache=924f463745523102',
'friendly_name': 'PlayStation 4',
'media_content_id': 'CUSA23081_00',
'media_content_type': <MediaType.GAME: 'game'>,
'media_title': 'Untitled Goose Game',
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'media_player.playstation_4',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---
# name: test_platform[PS5_idle][media_player.playstation_5-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.playstation_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': None,
'platform': 'playstation_network',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'playstation',
'unique_id': 'my-psn-id_PS5',
'unit_of_measurement': None,
})
# ---
# name: test_platform[PS5_idle][media_player.playstation_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'entity_picture_local': None,
'friendly_name': 'PlayStation 5',
'media_content_type': <MediaType.GAME: 'game'>,
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'media_player.playstation_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_platform[PS5_offline][media_player.playstation_5-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.playstation_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': None,
'platform': 'playstation_network',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'playstation',
'unique_id': 'my-psn-id_PS5',
'unit_of_measurement': None,
})
# ---
# name: test_platform[PS5_offline][media_player.playstation_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'friendly_name': 'PlayStation 5',
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'media_player.playstation_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_platform[PS5_playing][media_player.playstation_5-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.playstation_5',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.RECEIVER: 'receiver'>,
'original_icon': None,
'original_name': None,
'platform': 'playstation_network',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'playstation',
'unique_id': 'my-psn-id_PS5',
'unit_of_measurement': None,
})
# ---
# name: test_platform[PS5_playing][media_player.playstation_5-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'receiver',
'entity_picture': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png',
'entity_picture_local': '/api/media_player_proxy/media_player.playstation_5?token=123456789&cache=50dfb7140be0060b',
'friendly_name': 'PlayStation 5',
'media_content_id': 'PPSA07784_00',
'media_content_type': <MediaType.GAME: 'game'>,
'media_title': 'STAR WARS Jedi: Survivor™',
'supported_features': <MediaPlayerEntityFeature: 0>,
}),
'context': <ANY>,
'entity_id': 'media_player.playstation_5',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'playing',
})
# ---

View File

@@ -0,0 +1,140 @@
"""Test the Playstation Network config flow."""
from unittest.mock import MagicMock
import pytest
from homeassistant.components.playstation_network.config_flow import (
PSNAWPAuthenticationError,
PSNAWPError,
PSNAWPInvalidTokenError,
PSNAWPNotFoundError,
)
from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import NPSSO_TOKEN, NPSSO_TOKEN_INVALID_JSON, PSN_ID
from tests.common import MockConfigEntry
MOCK_DATA_ADVANCED_STEP = {CONF_NPSSO: NPSSO_TOKEN}
async def test_manual_config(hass: HomeAssistant, mock_psnawpapi: MagicMock) -> None:
"""Test creating via manual configuration."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_NPSSO: "TEST_NPSSO_TOKEN"},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["result"].unique_id == PSN_ID
assert result["data"] == {
CONF_NPSSO: "TEST_NPSSO_TOKEN",
}
async def test_form_already_configured(
hass: HomeAssistant,
config_entry: MockConfigEntry,
mock_psnawpapi: MagicMock,
) -> None:
"""Test we abort form login when entry is already configured."""
config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_NPSSO: NPSSO_TOKEN},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("raise_error", "text_error"),
[
(PSNAWPNotFoundError(), "invalid_account"),
(PSNAWPAuthenticationError(), "invalid_auth"),
(PSNAWPError(), "cannot_connect"),
(Exception(), "unknown"),
],
)
async def test_form_failures(
hass: HomeAssistant,
mock_psnawpapi: MagicMock,
raise_error: Exception,
text_error: str,
) -> None:
"""Test we handle a connection error.
First we generate an error and after fixing it, we are still able to submit.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
mock_psnawpapi.user.side_effect = raise_error
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_NPSSO: NPSSO_TOKEN},
)
assert result["step_id"] == "user"
assert result["errors"] == {"base": text_error}
mock_psnawpapi.user.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_NPSSO: NPSSO_TOKEN},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_NPSSO: NPSSO_TOKEN,
}
@pytest.mark.usefixtures("mock_psnawpapi")
async def test_parse_npsso_token_failures(
hass: HomeAssistant,
mock_psnawp_npsso: MagicMock,
) -> None:
"""Test parse_npsso_token raises the correct exceptions during config flow."""
mock_psnawp_npsso.parse_npsso_token.side_effect = PSNAWPInvalidTokenError
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={CONF_NPSSO: NPSSO_TOKEN_INVALID_JSON},
)
assert result["errors"] == {"base": "invalid_account"}
mock_psnawp_npsso.parse_npsso_token.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_NPSSO: NPSSO_TOKEN},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"] == {
CONF_NPSSO: NPSSO_TOKEN,
}

View File

@@ -0,0 +1,123 @@
"""Test the Playstation Network media player platform."""
from collections.abc import AsyncGenerator
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.fixture(autouse=True)
async def media_player_only() -> AsyncGenerator[None]:
"""Enable only the media_player platform."""
with patch(
"homeassistant.components.playstation_network.PLATFORMS",
[Platform.MEDIA_PLAYER],
):
yield
@pytest.mark.parametrize(
"presence_payload",
[
{
"basicPresence": {
"availability": "availableToPlay",
"primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"},
"gameTitleInfoList": [
{
"npTitleId": "PPSA07784_00",
"titleName": "STAR WARS Jedi: Survivor™",
"format": "PS5",
"launchPlatform": "PS5",
"conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png",
}
],
}
},
{
"basicPresence": {
"availability": "availableToPlay",
"primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS4"},
"gameTitleInfoList": [
{
"npTitleId": "CUSA23081_00",
"titleName": "Untitled Goose Game",
"format": "PS4",
"launchPlatform": "PS4",
"npTitleIconUrl": "http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png",
}
],
}
},
{
"basicPresence": {
"availability": "unavailable",
"lastAvailableDate": "2025-05-02T17:47:59.392Z",
"primaryPlatformInfo": {
"onlineStatus": "offline",
"platform": "PS5",
"lastOnlineDate": "2025-05-02T17:47:59.392Z",
},
}
},
{
"basicPresence": {
"availability": "unavailable",
"lastAvailableDate": "2025-05-02T17:47:59.392Z",
"primaryPlatformInfo": {
"onlineStatus": "offline",
"platform": "PS4",
"lastOnlineDate": "2025-05-02T17:47:59.392Z",
},
}
},
{
"basicPresence": {
"availability": "availableToPlay",
"primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"},
}
},
{
"basicPresence": {
"availability": "availableToPlay",
"primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS4"},
}
},
],
ids=[
"PS5_playing",
"PS4_playing",
"PS5_offline",
"PS4_offline",
"PS5_idle",
"PS4_idle",
],
)
@pytest.mark.usefixtures("mock_psnawpapi", "mock_token")
async def test_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_psnawpapi: MagicMock,
presence_payload: dict[str, Any],
) -> None:
"""Test setup of the PlayStation Network media_player platform."""
mock_psnawpapi.user().get_presence.return_value = presence_payload
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)