mirror of
https://github.com/home-assistant/core.git
synced 2026-08-06 21:35:13 +01:00
Add image platform to Steam integration (#176346)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
co-authored by
Joost Lekkerkerker
parent
80602d7b49
commit
2e99a5e2aa
@@ -10,7 +10,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from .const import CONF_ACCOUNTS, DOMAIN, SUBENTRY_TYPE_FRIEND
|
||||
from .coordinator import SteamConfigEntry, SteamDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
PLATFORMS = [Platform.IMAGE, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: SteamConfigEntry) -> bool:
|
||||
|
||||
@@ -30,7 +30,7 @@ STEAM_STATUSES = {
|
||||
5: STATE_LOOKING_TO_TRADE,
|
||||
6: STATE_LOOKING_TO_PLAY,
|
||||
}
|
||||
STEAM_API_URL = "https://steamcdn-a.akamaihd.net/steam/apps/"
|
||||
STEAM_API_URL = "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/"
|
||||
STEAM_HEADER_IMAGE_FILE = "header.jpg"
|
||||
STEAM_MAIN_IMAGE_FILE = "capsule_616x353.jpg"
|
||||
STEAM_ICON_URL = "https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/"
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Entity classes for the Steam integration."""
|
||||
|
||||
from homeassistant.components.sensor import SensorEntityDescription
|
||||
from typing import override
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -17,7 +19,7 @@ class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]):
|
||||
self,
|
||||
coordinator: SteamDataUpdateCoordinator,
|
||||
steamid: str,
|
||||
description: SensorEntityDescription,
|
||||
description: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize a Steam entity."""
|
||||
super().__init__(coordinator)
|
||||
@@ -25,10 +27,16 @@ class SteamEntity(CoordinatorEntity[SteamDataUpdateCoordinator]):
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{steamid}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
configuration_url=str(coordinator.data[steamid].profileurl),
|
||||
configuration_url=coordinator.data[steamid].profileurl,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
identifiers={(DOMAIN, steamid)},
|
||||
model="Steam",
|
||||
manufacturer="Valve",
|
||||
name=str(coordinator.data[steamid].personaname),
|
||||
name=coordinator.data[steamid].personaname,
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._steamid in self.coordinator.data
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"entity": {
|
||||
"image": {
|
||||
"avatar": {
|
||||
"default": "mdi:account-circle"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"account": {
|
||||
"default": "mdi:steam"
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Image platform for the Steam integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
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 .const import STEAM_API_URL, STEAM_ICON_URL, SUBENTRY_TYPE_FRIEND
|
||||
from .coordinator import PlayerData, SteamConfigEntry, SteamDataUpdateCoordinator
|
||||
from .entity import SteamEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class SteamImage(StrEnum):
|
||||
"""Steam images."""
|
||||
|
||||
AVATAR = "avatar"
|
||||
MAIN_CAPSULE = "main_capsule"
|
||||
HEADER_CAPSULE = "header_capsule"
|
||||
SMALL_CAPSULE = "small_capsule"
|
||||
VERTICAL_CAPSULE = "vertical_capsule"
|
||||
LIBRARY_CAPSULE = "library_capsule"
|
||||
LIBRARY_HERO = "library_hero"
|
||||
LIBRARY_LOGO = "library_logo"
|
||||
PAGE_BACKGROUND = "page_background"
|
||||
APP_ICON = "app_icon"
|
||||
|
||||
|
||||
@dataclass(kw_only=True, frozen=True)
|
||||
class SteamImageEntityDescription(ImageEntityDescription):
|
||||
"""Steam image description."""
|
||||
|
||||
image_url_fn: Callable[[PlayerData, dict[str, str]], str | None]
|
||||
available_fn: Callable[[PlayerData], bool] = lambda x: x.gameid is not None
|
||||
|
||||
|
||||
IMAGE_DESCRIPTIONS: tuple[SteamImageEntityDescription, ...] = (
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.AVATAR,
|
||||
translation_key=SteamImage.AVATAR,
|
||||
image_url_fn=lambda x, _: x.avatarfull,
|
||||
entity_registry_enabled_default=False,
|
||||
available_fn=lambda _: True,
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.MAIN_CAPSULE,
|
||||
translation_key=SteamImage.MAIN_CAPSULE,
|
||||
image_url_fn=lambda x, _: (
|
||||
f"{STEAM_API_URL}{x.gameid}/capsule_616x353.jpg" if x.gameid else None
|
||||
),
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.HEADER_CAPSULE,
|
||||
translation_key=SteamImage.HEADER_CAPSULE,
|
||||
image_url_fn=lambda x, _: (
|
||||
f"{STEAM_API_URL}{x.gameid}/header.jpg" if x.gameid else None
|
||||
),
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.APP_ICON,
|
||||
translation_key=SteamImage.APP_ICON,
|
||||
image_url_fn=lambda x, icons: (
|
||||
f"{STEAM_ICON_URL}{x.gameid}/{i}.jpg"
|
||||
if x.gameid and (i := icons.get(x.gameid))
|
||||
else None
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.SMALL_CAPSULE,
|
||||
translation_key=SteamImage.SMALL_CAPSULE,
|
||||
image_url_fn=lambda x, _: (
|
||||
f"{STEAM_API_URL}{x.gameid}/capsule_231x87.jpg" if x.gameid else None
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.LIBRARY_CAPSULE,
|
||||
translation_key=SteamImage.LIBRARY_CAPSULE,
|
||||
image_url_fn=lambda x, _: (
|
||||
f"{STEAM_API_URL}{x.gameid}/library_600x900_2x.jpg" if x.gameid else None
|
||||
),
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.LIBRARY_HERO,
|
||||
translation_key=SteamImage.LIBRARY_HERO,
|
||||
image_url_fn=lambda x, _: (
|
||||
f"{STEAM_API_URL}{x.gameid}/library_hero.jpg" if x.gameid else None
|
||||
),
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.LIBRARY_LOGO,
|
||||
translation_key=SteamImage.LIBRARY_LOGO,
|
||||
image_url_fn=lambda x, _: (
|
||||
f"{STEAM_API_URL}{x.gameid}/logo.png" if x.gameid else None
|
||||
),
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.PAGE_BACKGROUND,
|
||||
translation_key=SteamImage.PAGE_BACKGROUND,
|
||||
image_url_fn=lambda x, _: (
|
||||
f"{STEAM_API_URL}{x.gameid}/page_bg_generated_v6b.jpg" if x.gameid else None
|
||||
),
|
||||
),
|
||||
SteamImageEntityDescription(
|
||||
key=SteamImage.VERTICAL_CAPSULE,
|
||||
translation_key=SteamImage.VERTICAL_CAPSULE,
|
||||
image_url_fn=lambda x, _: (
|
||||
f"{STEAM_API_URL}{x.gameid}/hero_capsule.jpg" if x.gameid else None
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: SteamConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Steam platform."""
|
||||
coordinator = entry.runtime_data
|
||||
if TYPE_CHECKING:
|
||||
assert entry.unique_id
|
||||
|
||||
async_add_entities(
|
||||
SteamImageEntity(hass, coordinator, entry.unique_id, description)
|
||||
for description in IMAGE_DESCRIPTIONS
|
||||
if entry.unique_id in coordinator.data
|
||||
)
|
||||
|
||||
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND):
|
||||
async_add_entities(
|
||||
[
|
||||
SteamImageEntity(hass, coordinator, subentry.unique_id, description)
|
||||
for description in IMAGE_DESCRIPTIONS
|
||||
if subentry.unique_id in coordinator.data
|
||||
],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class SteamImageEntity(SteamEntity, ImageEntity):
|
||||
"""An image entity."""
|
||||
|
||||
entity_description: SteamImageEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
coordinator: SteamDataUpdateCoordinator,
|
||||
steamid: str,
|
||||
entity_description: SteamImageEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the image entity."""
|
||||
super().__init__(coordinator, steamid, entity_description)
|
||||
ImageEntity.__init__(self, hass)
|
||||
|
||||
self._attr_image_url = self.entity_description.image_url_fn(
|
||||
self.coordinator.data[self._steamid], self.coordinator.game_icons
|
||||
)
|
||||
self._attr_image_last_updated = dt_util.utcnow()
|
||||
|
||||
@override
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
|
||||
url = self.entity_description.image_url_fn(
|
||||
self.coordinator.data[self._steamid], self.coordinator.game_icons
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
@property
|
||||
@override
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.entity_description.available_fn(
|
||||
self.coordinator.data[self._steamid]
|
||||
)
|
||||
@@ -172,9 +172,3 @@ class SteamSensorEntity(SteamEntity, SensorEntity):
|
||||
if (fn := self.entity_description.extra_state_attributes_fn) is not None
|
||||
else super().extra_state_attributes
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._steamid in self.coordinator.data
|
||||
|
||||
@@ -83,6 +83,38 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"image": {
|
||||
"app_icon": {
|
||||
"name": "App icon"
|
||||
},
|
||||
"avatar": {
|
||||
"name": "Avatar"
|
||||
},
|
||||
"header_capsule": {
|
||||
"name": "Header capsule"
|
||||
},
|
||||
"library_capsule": {
|
||||
"name": "Library capsule"
|
||||
},
|
||||
"library_hero": {
|
||||
"name": "Library hero capsule"
|
||||
},
|
||||
"library_logo": {
|
||||
"name": "Library logo"
|
||||
},
|
||||
"main_capsule": {
|
||||
"name": "Main capsule"
|
||||
},
|
||||
"page_background": {
|
||||
"name": "Page background"
|
||||
},
|
||||
"small_capsule": {
|
||||
"name": "Small capsule"
|
||||
},
|
||||
"vertical_capsule": {
|
||||
"name": "Vertical capsule"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"account": {
|
||||
"state": {
|
||||
|
||||
@@ -15,6 +15,20 @@
|
||||
"rtime_last_played": 1782145718,
|
||||
"content_descriptorids": [1, 2, 5],
|
||||
"playtime_disconnected": 0
|
||||
},
|
||||
{
|
||||
"appid": 1180660,
|
||||
"name": "Tell Me Why",
|
||||
"playtime_forever": 1267,
|
||||
"img_icon_url": "6eb40e6f71226ce5bea75601a1ec0f9d6f647dd1",
|
||||
"has_community_visible_stats": true,
|
||||
"playtime_windows_forever": 1267,
|
||||
"playtime_mac_forever": 0,
|
||||
"playtime_linux_forever": 0,
|
||||
"playtime_deck_forever": 0,
|
||||
"rtime_last_played": 1642893397,
|
||||
"content_descriptorids": [5],
|
||||
"playtime_disconnected": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"response": {
|
||||
"players": {
|
||||
"player": [
|
||||
{
|
||||
"steamid": "12345678901234567",
|
||||
"communityvisibilitystate": 1,
|
||||
"profilestate": 1,
|
||||
"personaname": "testaccount1",
|
||||
"profileurl": "https://steamcommunity.com/profiles/123456789/",
|
||||
"avatar": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg",
|
||||
"avatarmedium": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_medium.jpg",
|
||||
"avatarfull": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg",
|
||||
"avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb",
|
||||
"lastlogoff": 1775409487,
|
||||
"personastate": 1,
|
||||
"realname": "John Dough",
|
||||
"personastateflags": 0,
|
||||
"gameextrainfo": "Tell Me Why",
|
||||
"gameid": "1180660"
|
||||
},
|
||||
{
|
||||
"steamid": "12345678912345678",
|
||||
"communityvisibilitystate": 1,
|
||||
"profilestate": 1,
|
||||
"personaname": "testaccount2",
|
||||
"profileurl": "https://steamcommunity.com/profiles/987654321/",
|
||||
"avatar": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg",
|
||||
"avatarmedium": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_medium.jpg",
|
||||
"avatarfull": "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg",
|
||||
"avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb",
|
||||
"lastlogoff": 1775409487,
|
||||
"personastate": 2,
|
||||
"personastateflags": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,8 +56,8 @@
|
||||
'game': 'The Witcher: Enhanced Edition',
|
||||
'game_icon': 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/20900/746d1cd48fb2e57d579b05b6e9eccba95859e549.jpg',
|
||||
'game_id': '20900',
|
||||
'game_image_header': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/header.jpg',
|
||||
'game_image_main': 'https://steamcdn-a.akamaihd.net/steam/apps/20900/capsule_616x353.jpg',
|
||||
'game_image_header': 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/20900/header.jpg',
|
||||
'game_image_main': 'https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/20900/capsule_616x353.jpg',
|
||||
'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc),
|
||||
'level': 10,
|
||||
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for Steam 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 syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.steam_online.const import DOMAIN, STEAM_API_URL
|
||||
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,
|
||||
async_fire_time_changed,
|
||||
async_load_json_object_fixture,
|
||||
snapshot_platform,
|
||||
)
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def image_only() -> Generator[None]:
|
||||
"""Enable only the image platform."""
|
||||
with patch(
|
||||
"homeassistant.components.steam_online.PLATFORMS",
|
||||
[Platform.IMAGE],
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_getrandbits():
|
||||
"""Mock image access token which normally is randomized."""
|
||||
with patch(
|
||||
"homeassistant.components.image.SystemRandom.getrandbits",
|
||||
return_value=1312,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("steam_api", "entity_registry_enabled_by_default")
|
||||
@pytest.mark.freeze_time("2013-12-13 12:13:12")
|
||||
async def test_images(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test setup of the Steam image platform."""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@respx.mock
|
||||
@pytest.mark.freeze_time("2013-12-13 12:13:12")
|
||||
async def test_load_image_from_url(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
hass_client: ClientSessionGenerator,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
steam_api: MagicMock,
|
||||
) -> None:
|
||||
"""Test image platform loads image from url."""
|
||||
|
||||
respx.get(f"{STEAM_API_URL}20900/capsule_616x353.jpg").respond(
|
||||
status_code=HTTPStatus.OK, content_type="image/jpg", 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.testaccount1_main_capsule"))
|
||||
assert state.state == "2013-12-13T12:13:12+00:00"
|
||||
|
||||
access_token = state.attributes["access_token"]
|
||||
assert (
|
||||
state.attributes["entity_picture"]
|
||||
== f"/api/image_proxy/image.testaccount1_main_capsule?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/jpg"
|
||||
assert resp.content_length == 4
|
||||
|
||||
steam_api.return_value.GetPlayerSummaries.return_value = (
|
||||
await async_load_json_object_fixture(hass, "GetPlayerSummaries2.json", DOMAIN)
|
||||
)
|
||||
|
||||
respx.get(f"{STEAM_API_URL}1180660/capsule_616x353.jpg").respond(
|
||||
status_code=HTTPStatus.OK, content_type="image/jpg", content=b"Test2"
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(seconds=30))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get("image.testaccount1_main_capsule"))
|
||||
assert state.state == "2013-12-13T12:13:42+00:00"
|
||||
|
||||
access_token = state.attributes["access_token"]
|
||||
assert (
|
||||
state.attributes["entity_picture"]
|
||||
== f"/api/image_proxy/image.testaccount1_main_capsule?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/jpg"
|
||||
assert resp.content_length == 5
|
||||
Reference in New Issue
Block a user