Add image platform to Steam integration (#176346)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Manu
2026-07-26 17:59:32 +02:00
committed by GitHub
co-authored by Joost Lekkerkerker
parent 80602d7b49
commit 2e99a5e2aa
12 changed files with 1459 additions and 14 deletions
@@ -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([
+131
View File
@@ -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