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:
parent
55eb11055c
commit
aee5d5126f
9 changed files with 418 additions and 2 deletions
|
@ -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(
|
||||
|
|
|
@ -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"
|
||||
|
|
35
homeassistant/components/mastodon/coordinator.py
Normal file
35
homeassistant/components/mastodon/coordinator.py
Normal 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
|
48
homeassistant/components/mastodon/entity.py
Normal file
48
homeassistant/components/mastodon/entity.py
Normal 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
|
15
homeassistant/components/mastodon/icons.json
Normal file
15
homeassistant/components/mastodon/icons.json
Normal file
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"followers": {
|
||||
"default": "mdi:account-multiple"
|
||||
},
|
||||
"following": {
|
||||
"default": "mdi:account-multiple"
|
||||
},
|
||||
"posts": {
|
||||
"default": "mdi:message-text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
85
homeassistant/components/mastodon/sensor.py
Normal file
85
homeassistant/components/mastodon/sensor.py
Normal 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)
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
151
tests/components/mastodon/snapshots/test_sensor.ambr
Normal file
151
tests/components/mastodon/snapshots/test_sensor.ambr
Normal 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',
|
||||
})
|
||||
# ---
|
27
tests/components/mastodon/test_sensor.py
Normal file
27
tests/components/mastodon/test_sensor.py
Normal 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)
|
Loading…
Add table
Reference in a new issue