Skip to content

Commit c671ff3

Browse files
JackJPowelltr4nt0rjoostlek
authored
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 <[email protected]> * 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 <[email protected]> Co-authored-by: Joost Lekkerkerker <[email protected]>
1 parent 646ddf9 commit c671ff3

File tree

21 files changed

+1317
-1
lines changed

21 files changed

+1317
-1
lines changed

CODEOWNERS

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

homeassistant/brands/sony.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
{
22
"domain": "sony",
33
"name": "Sony",
4-
"integrations": ["braviatv", "ps4", "sony_projector", "songpal"]
4+
"integrations": [
5+
"braviatv",
6+
"ps4",
7+
"sony_projector",
8+
"songpal",
9+
"playstation_network"
10+
]
511
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""The PlayStation Network integration."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.const import Platform
6+
from homeassistant.core import HomeAssistant
7+
8+
from .const import CONF_NPSSO
9+
from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator
10+
from .helpers import PlaystationNetwork
11+
12+
PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]
13+
14+
15+
async def async_setup_entry(
16+
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
17+
) -> bool:
18+
"""Set up Playstation Network from a config entry."""
19+
20+
psn = PlaystationNetwork(hass, entry.data[CONF_NPSSO])
21+
22+
coordinator = PlaystationNetworkCoordinator(hass, psn, entry)
23+
await coordinator.async_config_entry_first_refresh()
24+
entry.runtime_data = coordinator
25+
26+
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
27+
return True
28+
29+
30+
async def async_unload_entry(
31+
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
32+
) -> bool:
33+
"""Unload a config entry."""
34+
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Config flow for the PlayStation Network integration."""
2+
3+
import logging
4+
from typing import Any
5+
6+
from psnawp_api.core.psnawp_exceptions import (
7+
PSNAWPAuthenticationError,
8+
PSNAWPError,
9+
PSNAWPInvalidTokenError,
10+
PSNAWPNotFoundError,
11+
)
12+
from psnawp_api.models.user import User
13+
from psnawp_api.utils.misc import parse_npsso_token
14+
import voluptuous as vol
15+
16+
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
17+
18+
from .const import CONF_NPSSO, DOMAIN
19+
from .helpers import PlaystationNetwork
20+
21+
_LOGGER = logging.getLogger(__name__)
22+
23+
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_NPSSO): str})
24+
25+
26+
class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
27+
"""Handle a config flow for Playstation Network."""
28+
29+
async def async_step_user(
30+
self, user_input: dict[str, Any] | None = None
31+
) -> ConfigFlowResult:
32+
"""Handle the initial step."""
33+
errors: dict[str, str] = {}
34+
npsso: str | None = None
35+
if user_input is not None:
36+
try:
37+
npsso = parse_npsso_token(user_input[CONF_NPSSO])
38+
except PSNAWPInvalidTokenError:
39+
errors["base"] = "invalid_account"
40+
41+
if npsso:
42+
psn = PlaystationNetwork(self.hass, npsso)
43+
try:
44+
user: User = await psn.get_user()
45+
except PSNAWPAuthenticationError:
46+
errors["base"] = "invalid_auth"
47+
except PSNAWPNotFoundError:
48+
errors["base"] = "invalid_account"
49+
except PSNAWPError:
50+
errors["base"] = "cannot_connect"
51+
except Exception:
52+
_LOGGER.exception("Unexpected exception")
53+
errors["base"] = "unknown"
54+
else:
55+
await self.async_set_unique_id(user.account_id)
56+
self._abort_if_unique_id_configured()
57+
return self.async_create_entry(
58+
title=user.online_id,
59+
data={CONF_NPSSO: npsso},
60+
)
61+
62+
return self.async_show_form(
63+
step_id="user",
64+
data_schema=STEP_USER_DATA_SCHEMA,
65+
errors=errors,
66+
description_placeholders={
67+
"npsso_link": "https://ca.account.sony.com/api/v1/ssocookie",
68+
"psn_link": "https://playstation.com",
69+
},
70+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Constants for the Playstation Network integration."""
2+
3+
from typing import Final
4+
5+
from psnawp_api.models.trophies import PlatformType
6+
7+
DOMAIN = "playstation_network"
8+
CONF_NPSSO: Final = "npsso"
9+
10+
SUPPORTED_PLATFORMS = {
11+
PlatformType.PS5,
12+
PlatformType.PS4,
13+
PlatformType.PS3,
14+
PlatformType.PSPC,
15+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Coordinator for the PlayStation Network Integration."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import timedelta
6+
import logging
7+
8+
from psnawp_api.core.psnawp_exceptions import (
9+
PSNAWPAuthenticationError,
10+
PSNAWPServerError,
11+
)
12+
from psnawp_api.models.user import User
13+
14+
from homeassistant.config_entries import ConfigEntry
15+
from homeassistant.core import HomeAssistant
16+
from homeassistant.exceptions import ConfigEntryNotReady
17+
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
18+
19+
from .const import DOMAIN
20+
from .helpers import PlaystationNetwork, PlaystationNetworkData
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
24+
type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator]
25+
26+
27+
class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]):
28+
"""Data update coordinator for PSN."""
29+
30+
config_entry: PlaystationNetworkConfigEntry
31+
user: User
32+
33+
def __init__(
34+
self,
35+
hass: HomeAssistant,
36+
psn: PlaystationNetwork,
37+
config_entry: PlaystationNetworkConfigEntry,
38+
) -> None:
39+
"""Initialize the Coordinator."""
40+
super().__init__(
41+
hass,
42+
name=DOMAIN,
43+
logger=_LOGGER,
44+
config_entry=config_entry,
45+
update_interval=timedelta(seconds=30),
46+
)
47+
48+
self.psn = psn
49+
50+
async def _async_setup(self) -> None:
51+
"""Set up the coordinator."""
52+
53+
try:
54+
self.user = await self.psn.get_user()
55+
except PSNAWPAuthenticationError as error:
56+
raise ConfigEntryNotReady(
57+
translation_domain=DOMAIN,
58+
translation_key="not_ready",
59+
) from error
60+
61+
async def _async_update_data(self) -> PlaystationNetworkData:
62+
"""Get the latest data from the PSN."""
63+
try:
64+
return await self.psn.get_data()
65+
except (PSNAWPAuthenticationError, PSNAWPServerError) as error:
66+
raise UpdateFailed(
67+
translation_domain=DOMAIN,
68+
translation_key="update_failed",
69+
) from error
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Helper methods for common PlayStation Network integration operations."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from functools import partial
7+
from typing import Any
8+
9+
from psnawp_api import PSNAWP
10+
from psnawp_api.core.psnawp_exceptions import PSNAWPNotFoundError
11+
from psnawp_api.models.client import Client
12+
from psnawp_api.models.trophies import PlatformType
13+
from psnawp_api.models.user import User
14+
from pyrate_limiter import Duration, Rate
15+
16+
from homeassistant.core import HomeAssistant
17+
18+
from .const import SUPPORTED_PLATFORMS
19+
20+
LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4}
21+
22+
23+
@dataclass
24+
class SessionData:
25+
"""Dataclass representing console session data."""
26+
27+
platform: PlatformType = PlatformType.UNKNOWN
28+
title_id: str | None = None
29+
title_name: str | None = None
30+
format: PlatformType | None = None
31+
media_image_url: str | None = None
32+
status: str = ""
33+
34+
35+
@dataclass
36+
class PlaystationNetworkData:
37+
"""Dataclass representing data retrieved from the Playstation Network api."""
38+
39+
presence: dict[str, Any] = field(default_factory=dict)
40+
username: str = ""
41+
account_id: str = ""
42+
available: bool = False
43+
active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict)
44+
registered_platforms: set[PlatformType] = field(default_factory=set)
45+
46+
47+
class PlaystationNetwork:
48+
"""Helper Class to return playstation network data in an easy to use structure."""
49+
50+
def __init__(self, hass: HomeAssistant, npsso: str) -> None:
51+
"""Initialize the class with the npsso token."""
52+
rate = Rate(300, Duration.MINUTE * 15)
53+
self.psn = PSNAWP(npsso, rate_limit=rate)
54+
self.client: Client | None = None
55+
self.hass = hass
56+
self.user: User
57+
self.legacy_profile: dict[str, Any] | None = None
58+
59+
async def get_user(self) -> User:
60+
"""Get the user object from the PlayStation Network."""
61+
self.user = await self.hass.async_add_executor_job(
62+
partial(self.psn.user, online_id="me")
63+
)
64+
return self.user
65+
66+
def retrieve_psn_data(self) -> PlaystationNetworkData:
67+
"""Bundle api calls to retrieve data from the PlayStation Network."""
68+
data = PlaystationNetworkData()
69+
70+
if not self.client:
71+
self.client = self.psn.me()
72+
73+
data.registered_platforms = {
74+
PlatformType(device["deviceType"])
75+
for device in self.client.get_account_devices()
76+
} & SUPPORTED_PLATFORMS
77+
78+
data.presence = self.user.get_presence()
79+
80+
# check legacy platforms if owned
81+
if LEGACY_PLATFORMS & data.registered_platforms:
82+
self.legacy_profile = self.client.get_profile_legacy()
83+
return data
84+
85+
async def get_data(self) -> PlaystationNetworkData:
86+
"""Get title data from the PlayStation Network."""
87+
data = await self.hass.async_add_executor_job(self.retrieve_psn_data)
88+
data.username = self.user.online_id
89+
data.account_id = self.user.account_id
90+
91+
data.available = (
92+
data.presence.get("basicPresence", {}).get("availability")
93+
== "availableToPlay"
94+
)
95+
96+
session = SessionData()
97+
session.platform = PlatformType(
98+
data.presence["basicPresence"]["primaryPlatformInfo"]["platform"]
99+
)
100+
101+
if session.platform in SUPPORTED_PLATFORMS:
102+
session.status = data.presence.get("basicPresence", {}).get(
103+
"primaryPlatformInfo"
104+
)["onlineStatus"]
105+
106+
game_title_info = data.presence.get("basicPresence", {}).get(
107+
"gameTitleInfoList"
108+
)
109+
110+
if game_title_info:
111+
session.title_id = game_title_info[0]["npTitleId"]
112+
session.title_name = game_title_info[0]["titleName"]
113+
session.format = PlatformType(game_title_info[0]["format"])
114+
if session.format in {PlatformType.PS5, PlatformType.PSPC}:
115+
session.media_image_url = game_title_info[0]["conceptIconUrl"]
116+
else:
117+
session.media_image_url = game_title_info[0]["npTitleIconUrl"]
118+
119+
data.active_sessions[session.platform] = session
120+
121+
if self.legacy_profile:
122+
presence = self.legacy_profile["profile"].get("presences", [])
123+
game_title_info = presence[0] if presence else {}
124+
session = SessionData()
125+
126+
# If primary console isn't online, check legacy platforms for status
127+
if not data.available:
128+
data.available = game_title_info["onlineStatus"] == "online"
129+
130+
if "npTitleId" in game_title_info:
131+
session.title_id = game_title_info["npTitleId"]
132+
session.title_name = game_title_info["titleName"]
133+
session.format = game_title_info["platform"]
134+
session.platform = game_title_info["platform"]
135+
session.status = game_title_info["onlineStatus"]
136+
if PlatformType(session.format) is PlatformType.PS4:
137+
session.media_image_url = game_title_info["npTitleIconUrl"]
138+
elif PlatformType(session.format) is PlatformType.PS3:
139+
try:
140+
title = self.psn.game_title(
141+
session.title_id, platform=PlatformType.PS3, account_id="me"
142+
)
143+
except PSNAWPNotFoundError:
144+
session.media_image_url = None
145+
146+
if title:
147+
session.media_image_url = title.get_title_icon_url()
148+
149+
if game_title_info["onlineStatus"] == "online":
150+
data.active_sessions[session.platform] = session
151+
return data
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"entity": {
3+
"media_player": {
4+
"playstation": {
5+
"default": "mdi:sony-playstation"
6+
}
7+
}
8+
}
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"domain": "playstation_network",
3+
"name": "PlayStation Network",
4+
"codeowners": ["@jackjpowell"],
5+
"config_flow": true,
6+
"documentation": "https://www.home-assistant.io/integrations/playstation_network",
7+
"integration_type": "service",
8+
"iot_class": "cloud_polling",
9+
"quality_scale": "bronze",
10+
"requirements": ["PSNAWP==3.0.0", "pyrate-limiter==3.7.0"]
11+
}

0 commit comments

Comments
 (0)