Add image platform to PlayStation Network (#148928)

This commit is contained in:
Manu 2025-07-18 08:33:30 +02:00 committed by GitHub
parent 50688bbd69
commit 414057d455
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 229 additions and 1 deletions

View File

@ -16,6 +16,7 @@ from .helpers import PlaystationNetwork
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.IMAGE,
Platform.MEDIA_PLAYER,
Platform.SENSOR,
]

View File

@ -43,11 +43,14 @@ class PlaystationNetworkData:
registered_platforms: set[PlatformType] = field(default_factory=set)
trophy_summary: TrophySummary | None = None
profile: dict[str, Any] = field(default_factory=dict)
shareable_profile_link: dict[str, str] = field(default_factory=dict)
class PlaystationNetwork:
"""Helper Class to return playstation network data in an easy to use structure."""
shareable_profile_link: dict[str, str]
def __init__(self, hass: HomeAssistant, npsso: str) -> None:
"""Initialize the class with the npsso token."""
rate = Rate(300, Duration.MINUTE * 15)
@ -63,6 +66,7 @@ class PlaystationNetwork:
"""Setup PSN."""
self.user = self.psn.user(online_id="me")
self.client = self.psn.me()
self.shareable_profile_link = self.client.get_shareable_profile_link()
self.trophy_titles = list(self.user.trophy_titles())
async def async_setup(self) -> None:
@ -100,7 +104,7 @@ class PlaystationNetwork:
data = await self.hass.async_add_executor_job(self.retrieve_psn_data)
data.username = self.user.online_id
data.account_id = self.user.account_id
data.shareable_profile_link = self.shareable_profile_link
data.availability = data.presence["basicPresence"]["availability"]
session = SessionData()

View File

@ -43,6 +43,14 @@
"offline": "mdi:account-off-outline"
}
}
},
"image": {
"share_profile": {
"default": "mdi:share-variant"
},
"avatar": {
"default": "mdi:account-circle"
}
}
}
}

View File

@ -0,0 +1,105 @@
"""Image platform for PlayStation Network."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import (
PlaystationNetworkConfigEntry,
PlaystationNetworkData,
PlaystationNetworkUserDataCoordinator,
)
from .entity import PlaystationNetworkServiceEntity
PARALLEL_UPDATES = 0
class PlaystationNetworkImage(StrEnum):
"""PlayStation Network images."""
AVATAR = "avatar"
SHARE_PROFILE = "share_profile"
@dataclass(kw_only=True, frozen=True)
class PlaystationNetworkImageEntityDescription(ImageEntityDescription):
"""Image entity description."""
image_url_fn: Callable[[PlaystationNetworkData], str | None]
IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = (
PlaystationNetworkImageEntityDescription(
key=PlaystationNetworkImage.SHARE_PROFILE,
translation_key=PlaystationNetworkImage.SHARE_PROFILE,
image_url_fn=lambda data: data.shareable_profile_link["shareImageUrl"],
),
PlaystationNetworkImageEntityDescription(
key=PlaystationNetworkImage.AVATAR,
translation_key=PlaystationNetworkImage.AVATAR,
image_url_fn=(
lambda data: next(
(
pic.get("url")
for pic in data.profile["avatars"]
if pic.get("size") == "xl"
),
None,
)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PlaystationNetworkConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up image platform."""
coordinator = config_entry.runtime_data.user_data
async_add_entities(
[
PlaystationNetworkImageEntity(hass, coordinator, description)
for description in IMAGE_DESCRIPTIONS
]
)
class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity):
"""An image entity."""
entity_description: PlaystationNetworkImageEntityDescription
def __init__(
self,
hass: HomeAssistant,
coordinator: PlaystationNetworkUserDataCoordinator,
entity_description: PlaystationNetworkImageEntityDescription,
) -> None:
"""Initialize the image entity."""
super().__init__(coordinator, entity_description)
ImageEntity.__init__(self, hass)
self._attr_image_url = self.entity_description.image_url_fn(coordinator.data)
self._attr_image_last_updated = dt_util.utcnow()
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
url = self.entity_description.image_url_fn(self.coordinator.data)
if url != self._attr_image_url:
self._attr_image_url = url
self._cached_image = None
self._attr_image_last_updated = dt_util.utcnow()
super()._handle_coordinator_update()

View File

@ -96,6 +96,14 @@
"busy": "Away"
}
}
},
"image": {
"share_profile": {
"name": "Share profile"
},
"avatar": {
"name": "Avatar"
}
}
}
}

View File

@ -156,6 +156,9 @@ 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"
}
yield client

View File

@ -71,6 +71,9 @@
'PS5',
'PSVITA',
]),
'shareable_profile_link': dict({
'shareImageUrl': 'https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493',
}),
'trophy_summary': dict({
'account_id': '**REDACTED**',
'earned_trophies': dict({

View File

@ -0,0 +1,96 @@
"""Test the PlayStation Network image platform."""
from collections.abc import Generator
from datetime import timedelta
from http import HTTPStatus
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
import respx
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.typing import ClientSessionGenerator
@pytest.fixture(autouse=True)
def image_only() -> Generator[None]:
"""Enable only the image platform."""
with patch(
"homeassistant.components.playstation_network.PLATFORMS",
[Platform.IMAGE],
):
yield
@respx.mock
@pytest.mark.usefixtures("mock_psnawpapi")
async def test_image_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,
hass_client: ClientSessionGenerator,
freezer: FrozenDateTimeFactory,
mock_psnawpapi: MagicMock,
) -> None:
"""Test image platform."""
freezer.move_to("2025-06-16T00:00:00-00:00")
respx.get(
"http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png"
).respond(status_code=HTTPStatus.OK, content_type="image/png", content=b"Test")
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
assert (state := hass.states.get("image.testuser_avatar"))
assert state.state == "2025-06-16T00:00:00+00:00"
access_token = state.attributes["access_token"]
assert (
state.attributes["entity_picture"]
== f"/api/image_proxy/image.testuser_avatar?token={access_token}"
)
client = await hass_client()
resp = await client.get(state.attributes["entity_picture"])
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == b"Test"
assert resp.content_type == "image/png"
assert resp.content_length == 4
ava = "https://static-resource.np.community.playstation.net/avatar_m/WWS_E/E0011_m.png"
profile = mock_psnawpapi.user.return_value.profile.return_value
profile["avatars"] = [{"size": "xl", "url": ava}]
mock_psnawpapi.user.return_value.profile.return_value = profile
respx.get(ava).respond(
status_code=HTTPStatus.OK, content_type="image/png", content=b"Test2"
)
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
await hass.async_block_till_done()
await hass.async_block_till_done()
assert (state := hass.states.get("image.testuser_avatar"))
assert state.state == "2025-06-16T00:00:30+00:00"
access_token = state.attributes["access_token"]
assert (
state.attributes["entity_picture"]
== f"/api/image_proxy/image.testuser_avatar?token={access_token}"
)
client = await hass_client()
resp = await client.get(state.attributes["entity_picture"])
assert resp.status == HTTPStatus.OK
body = await resp.read()
assert body == b"Test2"
assert resp.content_type == "image/png"
assert resp.content_length == 5