Skip to content

Commit 414057d

Browse files
authored
Add image platform to PlayStation Network (home-assistant#148928)
1 parent 50688bb commit 414057d

File tree

8 files changed

+229
-1
lines changed

8 files changed

+229
-1
lines changed

homeassistant/components/playstation_network/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
PLATFORMS: list[Platform] = [
1818
Platform.BINARY_SENSOR,
19+
Platform.IMAGE,
1920
Platform.MEDIA_PLAYER,
2021
Platform.SENSOR,
2122
]

homeassistant/components/playstation_network/helpers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,14 @@ class PlaystationNetworkData:
4343
registered_platforms: set[PlatformType] = field(default_factory=set)
4444
trophy_summary: TrophySummary | None = None
4545
profile: dict[str, Any] = field(default_factory=dict)
46+
shareable_profile_link: dict[str, str] = field(default_factory=dict)
4647

4748

4849
class PlaystationNetwork:
4950
"""Helper Class to return playstation network data in an easy to use structure."""
5051

52+
shareable_profile_link: dict[str, str]
53+
5154
def __init__(self, hass: HomeAssistant, npsso: str) -> None:
5255
"""Initialize the class with the npsso token."""
5356
rate = Rate(300, Duration.MINUTE * 15)
@@ -63,6 +66,7 @@ def _setup(self) -> None:
6366
"""Setup PSN."""
6467
self.user = self.psn.user(online_id="me")
6568
self.client = self.psn.me()
69+
self.shareable_profile_link = self.client.get_shareable_profile_link()
6670
self.trophy_titles = list(self.user.trophy_titles())
6771

6872
async def async_setup(self) -> None:
@@ -100,7 +104,7 @@ async def get_data(self) -> PlaystationNetworkData:
100104
data = await self.hass.async_add_executor_job(self.retrieve_psn_data)
101105
data.username = self.user.online_id
102106
data.account_id = self.user.account_id
103-
107+
data.shareable_profile_link = self.shareable_profile_link
104108
data.availability = data.presence["basicPresence"]["availability"]
105109

106110
session = SessionData()

homeassistant/components/playstation_network/icons.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
4343
"offline": "mdi:account-off-outline"
4444
}
4545
}
46+
},
47+
"image": {
48+
"share_profile": {
49+
"default": "mdi:share-variant"
50+
},
51+
"avatar": {
52+
"default": "mdi:account-circle"
53+
}
4654
}
4755
}
4856
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Image platform for PlayStation Network."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable
6+
from dataclasses import dataclass
7+
from enum import StrEnum
8+
9+
from homeassistant.components.image import ImageEntity, ImageEntityDescription
10+
from homeassistant.core import HomeAssistant
11+
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
12+
from homeassistant.util import dt as dt_util
13+
14+
from .coordinator import (
15+
PlaystationNetworkConfigEntry,
16+
PlaystationNetworkData,
17+
PlaystationNetworkUserDataCoordinator,
18+
)
19+
from .entity import PlaystationNetworkServiceEntity
20+
21+
PARALLEL_UPDATES = 0
22+
23+
24+
class PlaystationNetworkImage(StrEnum):
25+
"""PlayStation Network images."""
26+
27+
AVATAR = "avatar"
28+
SHARE_PROFILE = "share_profile"
29+
30+
31+
@dataclass(kw_only=True, frozen=True)
32+
class PlaystationNetworkImageEntityDescription(ImageEntityDescription):
33+
"""Image entity description."""
34+
35+
image_url_fn: Callable[[PlaystationNetworkData], str | None]
36+
37+
38+
IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = (
39+
PlaystationNetworkImageEntityDescription(
40+
key=PlaystationNetworkImage.SHARE_PROFILE,
41+
translation_key=PlaystationNetworkImage.SHARE_PROFILE,
42+
image_url_fn=lambda data: data.shareable_profile_link["shareImageUrl"],
43+
),
44+
PlaystationNetworkImageEntityDescription(
45+
key=PlaystationNetworkImage.AVATAR,
46+
translation_key=PlaystationNetworkImage.AVATAR,
47+
image_url_fn=(
48+
lambda data: next(
49+
(
50+
pic.get("url")
51+
for pic in data.profile["avatars"]
52+
if pic.get("size") == "xl"
53+
),
54+
None,
55+
)
56+
),
57+
),
58+
)
59+
60+
61+
async def async_setup_entry(
62+
hass: HomeAssistant,
63+
config_entry: PlaystationNetworkConfigEntry,
64+
async_add_entities: AddConfigEntryEntitiesCallback,
65+
) -> None:
66+
"""Set up image platform."""
67+
68+
coordinator = config_entry.runtime_data.user_data
69+
70+
async_add_entities(
71+
[
72+
PlaystationNetworkImageEntity(hass, coordinator, description)
73+
for description in IMAGE_DESCRIPTIONS
74+
]
75+
)
76+
77+
78+
class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity):
79+
"""An image entity."""
80+
81+
entity_description: PlaystationNetworkImageEntityDescription
82+
83+
def __init__(
84+
self,
85+
hass: HomeAssistant,
86+
coordinator: PlaystationNetworkUserDataCoordinator,
87+
entity_description: PlaystationNetworkImageEntityDescription,
88+
) -> None:
89+
"""Initialize the image entity."""
90+
super().__init__(coordinator, entity_description)
91+
ImageEntity.__init__(self, hass)
92+
93+
self._attr_image_url = self.entity_description.image_url_fn(coordinator.data)
94+
self._attr_image_last_updated = dt_util.utcnow()
95+
96+
def _handle_coordinator_update(self) -> None:
97+
"""Handle updated data from the coordinator."""
98+
url = self.entity_description.image_url_fn(self.coordinator.data)
99+
100+
if url != self._attr_image_url:
101+
self._attr_image_url = url
102+
self._cached_image = None
103+
self._attr_image_last_updated = dt_util.utcnow()
104+
105+
super()._handle_coordinator_update()

homeassistant/components/playstation_network/strings.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@
9696
"busy": "Away"
9797
}
9898
}
99+
},
100+
"image": {
101+
"share_profile": {
102+
"name": "Share profile"
103+
},
104+
"avatar": {
105+
"name": "Avatar"
106+
}
99107
}
100108
}
101109
}

tests/components/playstation_network/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]:
156156
]
157157
}
158158
}
159+
client.me.return_value.get_shareable_profile_link.return_value = {
160+
"shareImageUrl": "https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493"
161+
}
159162
yield client
160163

161164

tests/components/playstation_network/snapshots/test_diagnostics.ambr

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@
7171
'PS5',
7272
'PSVITA',
7373
]),
74+
'shareable_profile_link': dict({
75+
'shareImageUrl': 'https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493',
76+
}),
7477
'trophy_summary': dict({
7578
'account_id': '**REDACTED**',
7679
'earned_trophies': dict({
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Test the PlayStation Network image platform."""
2+
3+
from collections.abc import Generator
4+
from datetime import timedelta
5+
from http import HTTPStatus
6+
from unittest.mock import MagicMock, patch
7+
8+
from freezegun.api import FrozenDateTimeFactory
9+
import pytest
10+
import respx
11+
12+
from homeassistant.config_entries import ConfigEntryState
13+
from homeassistant.const import Platform
14+
from homeassistant.core import HomeAssistant
15+
16+
from tests.common import MockConfigEntry, async_fire_time_changed
17+
from tests.typing import ClientSessionGenerator
18+
19+
20+
@pytest.fixture(autouse=True)
21+
def image_only() -> Generator[None]:
22+
"""Enable only the image platform."""
23+
with patch(
24+
"homeassistant.components.playstation_network.PLATFORMS",
25+
[Platform.IMAGE],
26+
):
27+
yield
28+
29+
30+
@respx.mock
31+
@pytest.mark.usefixtures("mock_psnawpapi")
32+
async def test_image_platform(
33+
hass: HomeAssistant,
34+
config_entry: MockConfigEntry,
35+
hass_client: ClientSessionGenerator,
36+
freezer: FrozenDateTimeFactory,
37+
mock_psnawpapi: MagicMock,
38+
) -> None:
39+
"""Test image platform."""
40+
freezer.move_to("2025-06-16T00:00:00-00:00")
41+
42+
respx.get(
43+
"http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png"
44+
).respond(status_code=HTTPStatus.OK, content_type="image/png", content=b"Test")
45+
config_entry.add_to_hass(hass)
46+
await hass.config_entries.async_setup(config_entry.entry_id)
47+
await hass.async_block_till_done()
48+
49+
assert config_entry.state is ConfigEntryState.LOADED
50+
51+
assert (state := hass.states.get("image.testuser_avatar"))
52+
assert state.state == "2025-06-16T00:00:00+00:00"
53+
54+
access_token = state.attributes["access_token"]
55+
assert (
56+
state.attributes["entity_picture"]
57+
== f"/api/image_proxy/image.testuser_avatar?token={access_token}"
58+
)
59+
60+
client = await hass_client()
61+
resp = await client.get(state.attributes["entity_picture"])
62+
assert resp.status == HTTPStatus.OK
63+
body = await resp.read()
64+
assert body == b"Test"
65+
assert resp.content_type == "image/png"
66+
assert resp.content_length == 4
67+
68+
ava = "https://static-resource.np.community.playstation.net/avatar_m/WWS_E/E0011_m.png"
69+
profile = mock_psnawpapi.user.return_value.profile.return_value
70+
profile["avatars"] = [{"size": "xl", "url": ava}]
71+
mock_psnawpapi.user.return_value.profile.return_value = profile
72+
respx.get(ava).respond(
73+
status_code=HTTPStatus.OK, content_type="image/png", content=b"Test2"
74+
)
75+
76+
freezer.tick(timedelta(seconds=30))
77+
async_fire_time_changed(hass)
78+
await hass.async_block_till_done()
79+
await hass.async_block_till_done()
80+
81+
assert (state := hass.states.get("image.testuser_avatar"))
82+
assert state.state == "2025-06-16T00:00:30+00:00"
83+
84+
access_token = state.attributes["access_token"]
85+
assert (
86+
state.attributes["entity_picture"]
87+
== f"/api/image_proxy/image.testuser_avatar?token={access_token}"
88+
)
89+
90+
client = await hass_client()
91+
resp = await client.get(state.attributes["entity_picture"])
92+
assert resp.status == HTTPStatus.OK
93+
body = await resp.read()
94+
assert body == b"Test2"
95+
assert resp.content_type == "image/png"
96+
assert resp.content_length == 5

0 commit comments

Comments
 (0)