Add sensor platform for Mastodon (#123434)

* Add account sensors

* Sensor icons

* Change sensors to use value_fn

* Add native unit of measurement

* Update native unit of measurement

* Change toots to posts

* Fix sensor icons

* Add device entry type

* Explain conditional naming

* Fixes from review

* Remove unnecessary constructor
This commit is contained in:
Andrew Jackson 2024-08-09 15:02:27 +01:00 committed by GitHub
parent 55eb11055c
commit aee5d5126f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 418 additions and 2 deletions

View file

@ -2,6 +2,9 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from mastodon.Mastodon import Mastodon, MastodonError
from homeassistant.config_entries import ConfigEntry
@ -17,14 +20,33 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import discovery
from .const import CONF_BASE_URL, DOMAIN
from .coordinator import MastodonCoordinator
from .utils import create_mastodon_client
PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@dataclass
class MastodonData:
"""Mastodon data type."""
client: Mastodon
instance: dict
account: dict
coordinator: MastodonCoordinator
type MastodonConfigEntry = ConfigEntry[MastodonData]
if TYPE_CHECKING:
from . import MastodonConfigEntry
async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool:
"""Set up Mastodon from a config entry."""
try:
client, _, _ = await hass.async_add_executor_job(
client, instance, account = await hass.async_add_executor_job(
setup_mastodon,
entry,
)
@ -34,6 +56,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
assert entry.unique_id
coordinator = MastodonCoordinator(hass, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = MastodonData(client, instance, account, coordinator)
await discovery.async_load_platform(
hass,
Platform.NOTIFY,
@ -42,9 +70,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
{},
)
await hass.config_entries.async_forward_entry_setups(
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
)
def setup_mastodon(entry: ConfigEntry) -> tuple[Mastodon, dict, dict]:
"""Get mastodon details."""
client = create_mastodon_client(

View file

@ -16,3 +16,6 @@ INSTANCE_VERSION: Final = "version"
INSTANCE_URI: Final = "uri"
INSTANCE_DOMAIN: Final = "domain"
ACCOUNT_USERNAME: Final = "username"
ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count"
ACCOUNT_FOLLOWING_COUNT: Final = "following_count"
ACCOUNT_STATUSES_COUNT: Final = "statuses_count"

View file

@ -0,0 +1,35 @@
"""Define an object to manage fetching Mastodon data."""
from __future__ import annotations
from datetime import timedelta
from typing import Any
from mastodon import Mastodon
from mastodon.Mastodon import MastodonError
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import LOGGER
class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Class to manage fetching Mastodon data."""
def __init__(self, hass: HomeAssistant, client: Mastodon) -> None:
"""Initialize coordinator."""
super().__init__(
hass, logger=LOGGER, name="Mastodon", update_interval=timedelta(hours=1)
)
self.client = client
async def _async_update_data(self) -> dict[str, Any]:
try:
account: dict = await self.hass.async_add_executor_job(
self.client.account_verify_credentials
)
except MastodonError as ex:
raise UpdateFailed(ex) from ex
return account

View file

@ -0,0 +1,48 @@
"""Base class for Mastodon entities."""
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import MastodonConfigEntry
from .const import DEFAULT_NAME, DOMAIN, INSTANCE_VERSION
from .coordinator import MastodonCoordinator
from .utils import construct_mastodon_username
class MastodonEntity(CoordinatorEntity[MastodonCoordinator]):
"""Defines a base Mastodon entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: MastodonCoordinator,
entity_description: EntityDescription,
data: MastodonConfigEntry,
) -> None:
"""Initialize Mastodon entity."""
super().__init__(coordinator)
unique_id = data.unique_id
assert unique_id is not None
self._attr_unique_id = f"{unique_id}_{entity_description.key}"
# Legacy yaml config default title is Mastodon, don't make name Mastodon Mastodon
name = "Mastodon"
if data.title != DEFAULT_NAME:
name = f"Mastodon {data.title}"
full_account_name = construct_mastodon_username(
data.runtime_data.instance, data.runtime_data.account
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
manufacturer="Mastodon gGmbH",
model=full_account_name,
entry_type=DeviceEntryType.SERVICE,
sw_version=data.runtime_data.instance[INSTANCE_VERSION],
name=name,
)
self.entity_description = entity_description

View file

@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"followers": {
"default": "mdi:account-multiple"
},
"following": {
"default": "mdi:account-multiple"
},
"posts": {
"default": "mdi:message-text"
}
}
}
}

View file

@ -0,0 +1,85 @@
"""Mastodon platform for sensor components."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import MastodonConfigEntry
from .const import (
ACCOUNT_FOLLOWERS_COUNT,
ACCOUNT_FOLLOWING_COUNT,
ACCOUNT_STATUSES_COUNT,
)
from .entity import MastodonEntity
@dataclass(frozen=True, kw_only=True)
class MastodonSensorEntityDescription(SensorEntityDescription):
"""Describes Mastodon sensor entity."""
value_fn: Callable[[dict[str, Any]], StateType]
ENTITY_DESCRIPTIONS = (
MastodonSensorEntityDescription(
key="followers",
translation_key="followers",
native_unit_of_measurement="accounts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT),
),
MastodonSensorEntityDescription(
key="following",
translation_key="following",
native_unit_of_measurement="accounts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT),
),
MastodonSensorEntityDescription(
key="posts",
translation_key="posts",
native_unit_of_measurement="posts",
state_class=SensorStateClass.TOTAL,
value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: MastodonConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the sensor platform for entity."""
coordinator = entry.runtime_data.coordinator
async_add_entities(
MastodonSensorEntity(
coordinator=coordinator,
entity_description=entity_description,
data=entry,
)
for entity_description in ENTITY_DESCRIPTIONS
)
class MastodonSensorEntity(MastodonEntity, SensorEntity):
"""A Mastodon sensor entity."""
entity_description: MastodonSensorEntityDescription
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View file

@ -35,5 +35,18 @@
"title": "YAML import failed with unknown error",
"description": "Configuring {integration_title} using YAML is being removed but there was an unknown error while importing your existing configuration.\nPlease use the UI to configure Mastodon. Don't forget to delete the YAML configuration."
}
},
"entity": {
"sensor": {
"followers": {
"name": "Followers"
},
"following": {
"name": "Following"
},
"posts": {
"name": "Posts"
}
}
}
}

View file

@ -0,0 +1,151 @@
# serializer version: 1
# name: test_sensors[sensor.mastodon_trwnh_mastodon_social_followers-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mastodon_trwnh_mastodon_social_followers',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Followers',
'platform': 'mastodon',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'followers',
'unique_id': 'client_id_followers',
'unit_of_measurement': 'accounts',
})
# ---
# name: test_sensors[sensor.mastodon_trwnh_mastodon_social_followers-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mastodon @trwnh@mastodon.social Followers',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'accounts',
}),
'context': <ANY>,
'entity_id': 'sensor.mastodon_trwnh_mastodon_social_followers',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '821',
})
# ---
# name: test_sensors[sensor.mastodon_trwnh_mastodon_social_following-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mastodon_trwnh_mastodon_social_following',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Following',
'platform': 'mastodon',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'following',
'unique_id': 'client_id_following',
'unit_of_measurement': 'accounts',
})
# ---
# name: test_sensors[sensor.mastodon_trwnh_mastodon_social_following-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mastodon @trwnh@mastodon.social Following',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'accounts',
}),
'context': <ANY>,
'entity_id': 'sensor.mastodon_trwnh_mastodon_social_following',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '178',
})
# ---
# name: test_sensors[sensor.mastodon_trwnh_mastodon_social_posts-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.TOTAL: 'total'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.mastodon_trwnh_mastodon_social_posts',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Posts',
'platform': 'mastodon',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'posts',
'unique_id': 'client_id_posts',
'unit_of_measurement': 'posts',
})
# ---
# name: test_sensors[sensor.mastodon_trwnh_mastodon_social_posts-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mastodon @trwnh@mastodon.social Posts',
'state_class': <SensorStateClass.TOTAL: 'total'>,
'unit_of_measurement': 'posts',
}),
'context': <ANY>,
'entity_id': 'sensor.mastodon_trwnh_mastodon_social_posts',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '33120',
})
# ---

View file

@ -0,0 +1,27 @@
"""Tests for the Mastodon sensors."""
from unittest.mock import AsyncMock, patch
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
async def test_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_mastodon_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the sensor entities."""
with patch("homeassistant.components.mastodon.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)