Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PlayStation Network Integration #133901

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions homeassistant/components/playstation_network/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""The PlayStation Network integration."""

from __future__ import annotations

from psnawp_api.psn import PlaystationNetwork

from homeassistant.const import Platform
from homeassistant.core import HomeAssistant

from .const import CONF_NPSSO
from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator

PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER]


async def async_setup_entry(
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
) -> bool:
"""Set up Playstation Network from a config entry."""

psn = PlaystationNetwork(entry.data[CONF_NPSSO])
user = await hass.async_add_executor_job(psn.get_user)

coordinator = PlaystationNetworkCoordinator(hass, psn, user)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True


async def async_unload_entry(
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
58 changes: 58 additions & 0 deletions homeassistant/components/playstation_network/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Config flow for the PlayStation Network integration."""

import logging
from typing import Any

from psnawp_api.core.psnawp_exceptions import PSNAWPAuthenticationError, PSNAWPException
from psnawp_api.models.user import User
from psnawp_api.psn import PlaystationNetwork
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult

from .const import CONF_NPSSO, DOMAIN

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_NPSSO): str})


class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Playstation Network."""

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
try:
npsso = PlaystationNetwork.parse_npsso_token(
user_input.get(CONF_NPSSO, "")
)
psn = PlaystationNetwork(npsso)
user: User = await self.hass.async_add_executor_job(psn.get_user)
except PSNAWPAuthenticationError:
errors["base"] = "invalid_auth"
except PSNAWPException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user.account_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user.online_id,
data={CONF_NPSSO: npsso},
)

return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"npsso_link": "https://ca.account.sony.com/api/v1/ssocookie",
"psn_link": "https://playstation.com",
},
)
6 changes: 6 additions & 0 deletions homeassistant/components/playstation_network/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Constants for the Playstation Network integration."""

from typing import Final

DOMAIN = "playstation_network"
CONF_NPSSO: Final = "npsso"
61 changes: 61 additions & 0 deletions homeassistant/components/playstation_network/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Coordinator for the PlayStation Network Integration."""

from __future__ import annotations

from datetime import timedelta
import logging

from psnawp_api.core.psnawp_exceptions import PSNAWPAuthenticationError
from psnawp_api.models.user import User
from psnawp_api.psn import PlaystationNetwork, PlaystationNetworkData

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator]


class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]):
"""Data update coordinator for PSN."""

config_entry: PlaystationNetworkConfigEntry

def __init__(
self, hass: HomeAssistant, psn: PlaystationNetwork, user: User
) -> None:
"""Initialize the Coordinator."""
super().__init__(
hass,
name=DOMAIN,
logger=_LOGGER,
update_interval=timedelta(seconds=30),
)

self.hass = hass
self.user: User = user
self.psn: PlaystationNetwork = psn

async def _async_update_data(self) -> PlaystationNetworkData:
"""Get the latest data from the PSN."""
try:
return await self.hass.async_add_executor_job(self.psn.get_data)
except PSNAWPAuthenticationError as error:
raise UpdateFailed(
DOMAIN,
"update_failed",
) from error

async def _async_setup(self) -> None:
try:
await self.hass.async_add_executor_job(self.psn.validate_connection)
except PSNAWPAuthenticationError as error:
raise ConfigEntryNotReady(
DOMAIN,
"not_ready",
) from error
9 changes: 9 additions & 0 deletions homeassistant/components/playstation_network/icons.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"entity": {
"media_player": {
"playstation": {
"default": "mdi:sony-playstation"
}
}
}
}
10 changes: 10 additions & 0 deletions homeassistant/components/playstation_network/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"domain": "playstation_network",
"name": "PlayStation Network",
"codeowners": ["@jackjpowell"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/playstation_network",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["PSNAWP-HA==2.3.1"]
}
146 changes: 146 additions & 0 deletions homeassistant/components/playstation_network/media_player.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Media player entity for the PlayStation Network Integration."""

from enum import StrEnum
import logging

from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityDescription,
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from . import PlaystationNetworkCoordinator
from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

PLATFORM_MAP = {"PS5": "PlayStation 5", "PS4": "PlayStation 4"}


class PlatformType(StrEnum):
"""PlayStation Platform Enum."""

PS5 = "PS5"
PS4 = "PS4"


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Media Player Entity Setup."""
coordinator: PlaystationNetworkCoordinator = config_entry.runtime_data

@callback
def add_entities() -> None:
if coordinator.data.platform is None:
username = coordinator.data.username
_LOGGER.warning(
"No console found associated with account: %s. -- Pending creation when available",
username,
)
return

async_add_entities(
MediaPlayer(coordinator, platform)
for platform in coordinator.data.registered_platforms
)
remove_listener()

remove_listener = coordinator.async_add_listener(add_entities)
add_entities()


class MediaPlayer(CoordinatorEntity[PlaystationNetworkCoordinator], MediaPlayerEntity):
"""Media player entity representing currently playing game."""

_attr_media_image_remotely_accessible = True
_attr_media_content_type = MediaType.GAME

def __init__(
self, coordinator: PlaystationNetworkCoordinator, platform: str
) -> None:
"""Initialize PSN MediaPlayer."""
super().__init__(coordinator)

self.entity_description = MediaPlayerEntityDescription(
key=platform,
translation_key="playstation",
device_class=MediaPlayerDeviceClass.RECEIVER,
name=self.coordinator.user.online_id,
has_entity_name=True,
)
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{self.entity_description.key}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.coordinator.user.account_id)},
name=self.coordinator.user.online_id,
manufacturer="Sony Interactive Entertainment",
model="PlayStation Network",
)

@property
def name(self) -> str:
"""Name getter."""
return PLATFORM_MAP[self.entity_description.key]

Comment on lines +74 to +95
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
self.entity_description = MediaPlayerEntityDescription(
key=platform,
translation_key="playstation",
device_class=MediaPlayerDeviceClass.RECEIVER,
name=self.coordinator.user.online_id,
has_entity_name=True,
)
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{self.entity_description.key}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.coordinator.user.account_id)},
name=self.coordinator.user.online_id,
manufacturer="Sony Interactive Entertainment",
model="PlayStation Network",
)
@property
def name(self) -> str:
"""Name getter."""
return PLATFORM_MAP[self.entity_description.key]
self.entity_description = MediaPlayerEntityDescription(
key=platform,
translation_key="playstation",
device_class=MediaPlayerDeviceClass.RECEIVER,
name=None,
has_entity_name=True,
)
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_{self.entity_description.key}"
)
self._attr_device_info = DeviceInfo(
via_device={(DOMAIN, coordinator.config_entry.unique_id)},
identifiers={(DOMAIN, self._attr_unique_id)},
name=PLATFORM_MAP[self.entity_description.key],
manufacturer="Sony Interactive Entertainment",
model=PLATFORM_MAP[self.entity_description.key],
)

IMHO each console type should be represented as individual device, and media player as the main entity, doesn't need a name, it can be set to None. via_device should added maybe later when more entities are added to the integration, this would link the devices to the psn account.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could be missing something, but doesn't this limit the user to adding a single account? When setup this way, each entity is named after only the device (PlayStation X) but what happens when I add my second PSN account? Now I have two devices that would be named the same. This is of course solvable, but it lead me to think it was the wrong approach. The device in this scenario is the user's PSN account, not the physical hardware. I think this makes sense since we are not able to directly talk to the console. We only get details that pertain to it via the playstation network. All the other sensors that the custom integration provides also aren't console dependent but are associated with the user account.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Home Assistant would name it media_player.playstation_5_2 , there won't be any conflicts.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, but isn't that "wrong"? In the UI, there is no affordance for the user to differentiate them. They are both named "PlayStation 5" and the entity ID only differs with an _2. Which one is associated with my primary account and which one with my alt account?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's quite usual behaviour, many integrations name entities by their device model, sometimes also manufacturer name prepended. Users can rename entities afterwards however they want.

@property
def state(self) -> MediaPlayerState:
"""Media Player state getter."""
if (
self.entity_description.key
== self.coordinator.data.platform.get("platform", "")
and self.coordinator.data.platform.get("onlineStatus", "") == "online"
):
if (
self.coordinator.data.available
and self.coordinator.data.title_metadata.get("npTitleId") is not None
):
return MediaPlayerState.PLAYING
return MediaPlayerState.ON
return MediaPlayerState.OFF

@property
def media_title(self) -> str | None:
"""Media title getter."""
if self.coordinator.data.title_metadata.get(
"npTitleId"
) and self.entity_description.key == self.coordinator.data.platform.get(
"platform", ""
):
return self.coordinator.data.title_metadata.get("titleName")
return None

@property
def media_image_url(self) -> str | None:
"""Media image url getter."""
if self.coordinator.data.title_metadata.get(
"npTitleId"
) and self.entity_description.key == self.coordinator.data.platform.get(
"platform", ""
):
title = self.coordinator.data.title_metadata
if title.get("format", "") == PlatformType.PS5:
return title.get("conceptIconUrl")

if title.get("format", "") == PlatformType.PS4:
return title.get("npTitleIconUrl")
return None

@property
def is_on(self) -> bool:
"""Is user available on the Playstation Network."""
return (
self.coordinator.data.available is True
and self.entity_description.key
== self.coordinator.data.platform.get("platform", "")
)
59 changes: 59 additions & 0 deletions homeassistant/components/playstation_network/quality_scale.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done

# Silver
action-exceptions: todo
config-entry-unloading: todo
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo

# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
Loading