Add sensors to Steam integration (#176146)

This commit is contained in:
Manu
2026-07-13 16:11:37 +02:00
committed by GitHub
parent 75f4e11aff
commit 2741fb85db
5 changed files with 432 additions and 41 deletions
@@ -3,6 +3,19 @@
"sensor": {
"account": {
"default": "mdi:steam"
},
"last_online": {
"default": "mdi:account-clock"
},
"level": {
"default": "mdi:trophy-award"
},
"now_playing": {
"default": "mdi:controller",
"state": {
"unavailable": "mdi:controller-off",
"unknown": "mdi:controller-off"
}
}
}
}
+79 -38
View File
@@ -1,12 +1,17 @@
"""Sensor for Steam account status."""
from collections.abc import Callable
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any, override
from typing import TYPE_CHECKING, Any, override
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -30,14 +35,20 @@ class SteamSensor(StrEnum):
"""Steam sensors."""
ACCOUNT = "account"
LAST_ONLINE = "last_online"
NOW_PLAYING = "now_playing"
LEVEL = "level"
@dataclass(kw_only=True, frozen=True)
class SteamSensorEntityDescription(SensorEntityDescription):
"""Steam sensor description."""
value_fn: Callable[[PlayerData], StateType]
entity_picture_fn: Callable[[PlayerData], str] | None = None
value_fn: Callable[[PlayerData], StateType | datetime]
entity_picture_fn: Callable[[PlayerData, dict[str, str]], str | None] | None = None
extra_state_attributes_fn: (
Callable[[PlayerData, dict[str, str]], Mapping[str, Any]] | None
) = None
SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = (
@@ -45,8 +56,58 @@ SENSOR_DESCRIPTIONS: tuple[SteamSensorEntityDescription, ...] = (
key=SteamSensor.ACCOUNT,
translation_key=SteamSensor.ACCOUNT,
value_fn=lambda x: STEAM_STATUSES[x.personastate],
entity_picture_fn=lambda x: x.avatarfull,
entity_picture_fn=lambda x, _: x.avatarfull,
name=None,
extra_state_attributes_fn=lambda x, icons: {
"real_name": x.realname,
"created": (
dt_util.as_local(dt_util.utc_from_timestamp(x.timecreated))
if x.timecreated is not None
else None
),
"game": x.gameextrainfo,
"game_id": x.gameid,
"game_image_header": (
f"{STEAM_API_URL}{x.gameid}/{STEAM_HEADER_IMAGE_FILE}"
if x.gameid is not None
else None
),
"game_image_main": (
f"{STEAM_API_URL}{x.gameid}/{STEAM_MAIN_IMAGE_FILE}"
if x.gameid is not None
else None
),
"game_icon": (
f"{STEAM_ICON_URL}{x.gameid}/{info}.jpg"
if x.gameid is not None and (info := icons.get(x.gameid)) is not None
else None
),
"last_online": dt_util.utc_from_timestamp(x.lastlogoff),
"level": x.level,
},
),
SteamSensorEntityDescription(
key=SteamSensor.LAST_ONLINE,
translation_key=SteamSensor.LAST_ONLINE,
value_fn=(lambda x: dt_util.utc_from_timestamp(x.lastlogoff)),
device_class=SensorDeviceClass.TIMESTAMP,
),
SteamSensorEntityDescription(
key=SteamSensor.NOW_PLAYING,
translation_key=SteamSensor.NOW_PLAYING,
value_fn=lambda x: x.gameextrainfo,
entity_picture_fn=lambda x, icons: (
f"{STEAM_ICON_URL}{x.gameid}/{game_icon_url}.jpg"
if x.gameid and (game_icon_url := icons.get(x.gameid))
else None
),
extra_state_attributes_fn=lambda x, _: {"app_id": x.gameid},
),
SteamSensorEntityDescription(
key=SteamSensor.LEVEL,
translation_key=SteamSensor.LEVEL,
value_fn=lambda x: x.level,
state_class=SensorStateClass.MEASUREMENT,
),
)
@@ -58,11 +119,12 @@ async def async_setup_entry(
) -> None:
"""Set up the Steam platform."""
coordinator = entry.runtime_data
if TYPE_CHECKING:
assert entry.unique_id
async_add_entities(
SteamSensorEntity(coordinator, entry.unique_id, description)
for description in SENSOR_DESCRIPTIONS
if entry.unique_id is not None and entry.unique_id in coordinator.data
if entry.unique_id in coordinator.data
)
for subentry in entry.get_subentries_of_type(SUBENTRY_TYPE_FRIEND):
@@ -70,8 +132,7 @@ async def async_setup_entry(
[
SteamSensorEntity(coordinator, subentry.unique_id, description)
for description in SENSOR_DESCRIPTIONS
if subentry.unique_id is not None
and subentry.unique_id in coordinator.data
if subentry.unique_id in coordinator.data
],
config_subentry_id=subentry.subentry_id,
)
@@ -84,7 +145,7 @@ class SteamSensorEntity(SteamEntity, SensorEntity):
@property
@override
def native_value(self) -> StateType:
def native_value(self) -> StateType | datetime:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data[self._steamid])
@@ -93,40 +154,20 @@ class SteamSensorEntity(SteamEntity, SensorEntity):
def entity_picture(self) -> str | None:
"""Return the entity picture to use in the frontend, if any."""
return (
fn(self.coordinator.data[self._steamid])
fn(self.coordinator.data[self._steamid], self.coordinator.game_icons)
if (fn := self.entity_description.entity_picture_fn) is not None
else super().entity_picture
)
@property
@override
def extra_state_attributes(self) -> dict[str, Any]:
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes of the sensor."""
player = self.coordinator.data[self._steamid]
attrs: dict[str, str | int | datetime] = {}
if game := player.gameextrainfo:
attrs["game"] = game
if game_id := player.gameid:
attrs["game_id"] = game_id
game_url = f"{STEAM_API_URL}{player.gameid}/"
attrs["game_image_header"] = f"{game_url}{STEAM_HEADER_IMAGE_FILE}"
attrs["game_image_main"] = f"{game_url}{STEAM_MAIN_IMAGE_FILE}"
if info := self._get_game_icon(player):
attrs["game_icon"] = f"{STEAM_ICON_URL}{game_id}/{info}.jpg"
if last_online := player.lastlogoff:
attrs["last_online"] = dt_util.as_local(
dt_util.utc_from_timestamp(last_online)
)
if level := self.coordinator.data[self._steamid].level:
attrs["level"] = level
return attrs
def _get_game_icon(self, player: PlayerData) -> str | None:
"""Get game icon identifier."""
if player.gameid is not None and player.gameid in self.coordinator.game_icons:
return self.coordinator.game_icons[player.gameid]
return None
return (
fn(self.coordinator.data[self._steamid], self.coordinator.game_icons)
if (fn := self.entity_description.extra_state_attributes_fn) is not None
else super().extra_state_attributes
)
@property
@override
@@ -95,13 +95,27 @@
"snooze": "Snooze"
},
"state_attributes": {
"created": { "name": "Account created" },
"game": { "name": "Game" },
"game_icon": { "name": "Game icon" },
"game_id": { "name": "Game ID" },
"game_image_header": { "name": "Game header image" },
"game_image_main": { "name": "Game image" },
"last_online": { "name": "Last online" },
"level": { "name": "Level" }
"level": { "name": "Level" },
"real_name": { "name": "Real name" }
}
},
"last_online": {
"name": "Last online"
},
"level": {
"name": "Level"
},
"now_playing": {
"name": "Now playing",
"state_attributes": {
"app_id": { "name": "Steam App ID" }
}
}
}
@@ -14,6 +14,8 @@
"avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb",
"lastlogoff": 1775409487,
"personastate": 1,
"primaryclanid": "1234567890123456",
"timecreated": 1273953511,
"realname": "John Dough",
"personastateflags": 0,
"gameextrainfo": "The Witcher: Enhanced Edition",
@@ -31,6 +33,7 @@
"avatarhash": "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb",
"lastlogoff": 1775409487,
"personastate": 2,
"timecreated": 1303243041,
"personastateflags": 0
}
]
@@ -39,6 +39,7 @@
# name: test_sensors[sensor.testaccount1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'created': datetime.datetime(2010, 5, 15, 12, 58, 31, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')),
<EntityStateAttribute.ENTITY_PICTURE: 'entity_picture'>: 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'testaccount1',
'game': 'The Witcher: Enhanced Edition',
@@ -46,8 +47,9 @@
'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',
'last_online': datetime.datetime(2026, 4, 5, 10, 18, 7, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')),
'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc),
'level': 10,
'real_name': 'John Dough',
}),
'context': <ANY>,
'entity_id': 'sensor.testaccount1',
@@ -57,6 +59,162 @@
'state': 'online',
})
# ---
# name: test_sensors[sensor.testaccount1_last_online-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.testaccount1_last_online',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last online',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last online',
'platform': 'steam_online',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <SteamSensor.LAST_ONLINE: 'last_online'>,
'unique_id': '12345678901234567_last_online',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.testaccount1_last_online-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'testaccount1 Last online',
}),
'context': <ANY>,
'entity_id': 'sensor.testaccount1_last_online',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2026-04-05T17:18:07+00:00',
})
# ---
# name: test_sensors[sensor.testaccount1_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.testaccount1_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Level',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Level',
'platform': 'steam_online',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <SteamSensor.LEVEL: 'level'>,
'unique_id': '12345678901234567_level',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.testaccount1_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'testaccount1 Level',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.testaccount1_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10',
})
# ---
# name: test_sensors[sensor.testaccount1_now_playing-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.testaccount1_now_playing',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Now playing',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Now playing',
'platform': 'steam_online',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <SteamSensor.NOW_PLAYING: 'now_playing'>,
'unique_id': '12345678901234567_now_playing',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.testaccount1_now_playing-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'app_id': '20900',
<EntityStateAttribute.ENTITY_PICTURE: 'entity_picture'>: 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/apps/20900/746d1cd48fb2e57d579b05b6e9eccba95859e549.jpg',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'testaccount1 Now playing',
}),
'context': <ANY>,
'entity_id': 'sensor.testaccount1_now_playing',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'The Witcher: Enhanced Edition',
})
# ---
# name: test_sensors[sensor.testaccount2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -97,10 +255,17 @@
# name: test_sensors[sensor.testaccount2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'created': datetime.datetime(2011, 4, 19, 12, 57, 21, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')),
<EntityStateAttribute.ENTITY_PICTURE: 'entity_picture'>: 'https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'testaccount2',
'last_online': datetime.datetime(2026, 4, 5, 10, 18, 7, tzinfo=zoneinfo.ZoneInfo(key='US/Pacific')),
'game': None,
'game_icon': None,
'game_id': None,
'game_image_header': None,
'game_image_main': None,
'last_online': datetime.datetime(2026, 4, 5, 17, 18, 7, tzinfo=datetime.timezone.utc),
'level': 10,
'real_name': None,
}),
'context': <ANY>,
'entity_id': 'sensor.testaccount2',
@@ -110,3 +275,158 @@
'state': 'busy',
})
# ---
# name: test_sensors[sensor.testaccount2_last_online-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.testaccount2_last_online',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Last online',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TIMESTAMP: 'timestamp'>,
'original_icon': None,
'original_name': 'Last online',
'platform': 'steam_online',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <SteamSensor.LAST_ONLINE: 'last_online'>,
'unique_id': '12345678912345678_last_online',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.testaccount2_last_online-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'timestamp',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'testaccount2 Last online',
}),
'context': <ANY>,
'entity_id': 'sensor.testaccount2_last_online',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2026-04-05T17:18:07+00:00',
})
# ---
# name: test_sensors[sensor.testaccount2_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.testaccount2_level',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Level',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Level',
'platform': 'steam_online',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <SteamSensor.LEVEL: 'level'>,
'unique_id': '12345678912345678_level',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.testaccount2_level-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'testaccount2 Level',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.testaccount2_level',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '10',
})
# ---
# name: test_sensors[sensor.testaccount2_now_playing-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.testaccount2_now_playing',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Now playing',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Now playing',
'platform': 'steam_online',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': <SteamSensor.NOW_PLAYING: 'now_playing'>,
'unique_id': '12345678912345678_now_playing',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.testaccount2_now_playing-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'app_id': None,
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'testaccount2 Now playing',
}),
'context': <ANY>,
'entity_id': 'sensor.testaccount2_now_playing',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---