Add DataUpdateCoordinator to Minecraft Server (#100075)

This commit is contained in:
elmurato 2023-09-19 15:59:58 +02:00 committed by GitHub
parent 2b8690d8bc
commit a2a62839bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 229 additions and 281 deletions

View File

@ -735,6 +735,7 @@ omit =
homeassistant/components/mill/sensor.py homeassistant/components/mill/sensor.py
homeassistant/components/minecraft_server/__init__.py homeassistant/components/minecraft_server/__init__.py
homeassistant/components/minecraft_server/binary_sensor.py homeassistant/components/minecraft_server/binary_sensor.py
homeassistant/components/minecraft_server/coordinator.py
homeassistant/components/minecraft_server/entity.py homeassistant/components/minecraft_server/entity.py
homeassistant/components/minecraft_server/sensor.py homeassistant/components/minecraft_server/sensor.py
homeassistant/components/minio/minio_helper.py homeassistant/components/minio/minio_helper.py

View File

@ -1,31 +1,17 @@
"""The Minecraft Server integration.""" """The Minecraft Server integration."""
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging import logging
from typing import Any from typing import Any
import aiodns
from mcstatus.server import JavaServer
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform from homeassistant.const import CONF_HOST, CONF_NAME, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.device_registry as dr import homeassistant.helpers.device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
import homeassistant.helpers.entity_registry as er import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.event import async_track_time_interval
from .const import ( from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
DOMAIN, from .coordinator import MinecraftServerCoordinator
KEY_LATENCY,
KEY_MOTD,
SCAN_INTERVAL,
SIGNAL_NAME_PREFIX,
SRV_RECORD_PREFIX,
)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@ -34,19 +20,20 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Minecraft Server from a config entry.""" """Set up Minecraft Server from a config entry."""
domain_data = hass.data.setdefault(DOMAIN, {})
# Create and store server instance.
config_entry_id = entry.entry_id
_LOGGER.debug( _LOGGER.debug(
"Creating server instance for '%s' (%s)", "Creating coordinator instance for '%s' (%s)",
entry.data[CONF_NAME], entry.data[CONF_NAME],
entry.data[CONF_HOST], entry.data[CONF_HOST],
) )
server = MinecraftServer(hass, config_entry_id, entry.data)
domain_data[config_entry_id] = server # Create coordinator instance.
await server.async_update() config_entry_id = entry.entry_id
server.start_periodic_update() coordinator = MinecraftServerCoordinator(hass, config_entry_id, entry.data)
await coordinator.async_config_entry_first_refresh()
# Store coordinator instance.
domain_data = hass.data.setdefault(DOMAIN, {})
domain_data[config_entry_id] = coordinator
# Set up platforms. # Set up platforms.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@ -57,7 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Unload Minecraft Server config entry.""" """Unload Minecraft Server config entry."""
config_entry_id = config_entry.entry_id config_entry_id = config_entry.entry_id
server = hass.data[DOMAIN][config_entry_id]
# Unload platforms. # Unload platforms.
unload_ok = await hass.config_entries.async_unload_platforms( unload_ok = await hass.config_entries.async_unload_platforms(
@ -65,7 +51,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
) )
# Clean up. # Clean up.
server.stop_periodic_update()
hass.data[DOMAIN].pop(config_entry_id) hass.data[DOMAIN].pop(config_entry_id)
return unload_ok return unload_ok
@ -165,181 +150,3 @@ def _migrate_entity_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]:
) )
return {"new_unique_id": new_unique_id} return {"new_unique_id": new_unique_id}
@dataclass
class MinecraftServerData:
"""Representation of Minecraft server data."""
latency: float | None = None
motd: str | None = None
players_max: int | None = None
players_online: int | None = None
players_list: list[str] | None = None
protocol_version: int | None = None
version: str | None = None
class MinecraftServer:
"""Representation of a Minecraft server."""
def __init__(
self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any]
) -> None:
"""Initialize server instance."""
self._hass = hass
# Server data
self.unique_id = unique_id
self.name = config_data[CONF_NAME]
self.host = config_data[CONF_HOST]
self.port = config_data[CONF_PORT]
self.online = False
self._last_status_request_failed = False
self.srv_record_checked = False
# 3rd party library instance
self._server = JavaServer(self.host, self.port)
# Data provided by 3rd party library
self.data: MinecraftServerData = MinecraftServerData()
# Dispatcher signal name
self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}"
# Callback for stopping periodic update.
self._stop_periodic_update: CALLBACK_TYPE | None = None
def start_periodic_update(self) -> None:
"""Start periodic execution of update method."""
self._stop_periodic_update = async_track_time_interval(
self._hass, self.async_update, timedelta(seconds=SCAN_INTERVAL)
)
def stop_periodic_update(self) -> None:
"""Stop periodic execution of update method."""
if self._stop_periodic_update:
self._stop_periodic_update()
async def async_check_connection(self) -> None:
"""Check server connection using a 'status' request and store connection status."""
# Check if host is a valid SRV record, if not already done.
if not self.srv_record_checked:
self.srv_record_checked = True
srv_record = await self._async_check_srv_record(self.host)
if srv_record is not None:
_LOGGER.debug(
"'%s' is a valid Minecraft SRV record ('%s:%s')",
self.host,
srv_record[CONF_HOST],
srv_record[CONF_PORT],
)
# Overwrite host, port and 3rd party library instance
# with data extracted out of SRV record.
self.host = srv_record[CONF_HOST]
self.port = srv_record[CONF_PORT]
self._server = JavaServer(self.host, self.port)
# Ping the server with a status request.
try:
await self._server.async_status()
self.online = True
except OSError as error:
_LOGGER.debug(
(
"Error occurred while trying to check the connection to '%s:%s' -"
" OSError: %s"
),
self.host,
self.port,
error,
)
self.online = False
async def _async_check_srv_record(self, host: str) -> dict[str, Any] | None:
"""Check if the given host is a valid Minecraft SRV record."""
srv_record = None
srv_query = None
try:
srv_query = await aiodns.DNSResolver().query(
host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV"
)
except aiodns.error.DNSError:
# 'host' is not a SRV record.
pass
else:
# 'host' is a valid SRV record, extract the data.
srv_record = {
CONF_HOST: srv_query[0].host,
CONF_PORT: srv_query[0].port,
}
return srv_record
async def async_update(self, now: datetime | None = None) -> None:
"""Get server data from 3rd party library and update properties."""
# Check connection status.
server_online_old = self.online
await self.async_check_connection()
server_online = self.online
# Inform user once about connection state changes if necessary.
if server_online_old and not server_online:
_LOGGER.warning("Connection to '%s:%s' lost", self.host, self.port)
elif not server_online_old and server_online:
_LOGGER.info("Connection to '%s:%s' (re-)established", self.host, self.port)
# Update the server properties if server is online.
if server_online:
await self._async_status_request()
# Notify sensors about new data.
async_dispatcher_send(self._hass, self.signal_name)
async def _async_status_request(self) -> None:
"""Request server status and update properties."""
try:
status_response = await self._server.async_status()
# Got answer to request, update properties.
self.data.version = status_response.version.name
self.data.protocol_version = status_response.version.protocol
self.data.players_online = status_response.players.online
self.data.players_max = status_response.players.max
self.data.latency = status_response.latency
self.data.motd = status_response.motd.to_plain()
self.data.players_list = []
if status_response.players.sample is not None:
for player in status_response.players.sample:
self.data.players_list.append(player.name)
self.data.players_list.sort()
# Inform user once about successful update if necessary.
if self._last_status_request_failed:
_LOGGER.info(
"Updating the properties of '%s:%s' succeeded again",
self.host,
self.port,
)
self._last_status_request_failed = False
except OSError as error:
# No answer to request, set all properties to unknown.
self.data.version = None
self.data.protocol_version = None
self.data.players_online = None
self.data.players_max = None
self.data.latency = None
self.data.players_list = None
self.data.motd = None
# Inform user once about failed update if necessary.
if not self._last_status_request_failed:
_LOGGER.warning(
"Updating the properties of '%s:%s' failed - OSError: %s",
self.host,
self.port,
error,
)
self._last_status_request_failed = True

View File

@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import MinecraftServer
from .const import DOMAIN, ICON_STATUS, KEY_STATUS from .const import DOMAIN, ICON_STATUS, KEY_STATUS
from .coordinator import MinecraftServerCoordinator
from .entity import MinecraftServerEntity from .entity import MinecraftServerEntity
@ -36,15 +36,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Minecraft Server binary sensor platform.""" """Set up the Minecraft Server binary sensor platform."""
server = hass.data[DOMAIN][config_entry.entry_id] coordinator = hass.data[DOMAIN][config_entry.entry_id]
# Add binary sensor entities. # Add binary sensor entities.
async_add_entities( async_add_entities(
[ [
MinecraftServerBinarySensorEntity(server, description) MinecraftServerBinarySensorEntity(coordinator, description)
for description in BINARY_SENSOR_DESCRIPTIONS for description in BINARY_SENSOR_DESCRIPTIONS
], ]
True,
) )
@ -55,15 +54,21 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit
def __init__( def __init__(
self, self,
server: MinecraftServer, coordinator: MinecraftServerCoordinator,
description: MinecraftServerBinarySensorEntityDescription, description: MinecraftServerBinarySensorEntityDescription,
) -> None: ) -> None:
"""Initialize binary sensor base entity.""" """Initialize binary sensor base entity."""
super().__init__(server=server) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{server.unique_id}-{description.key}" self._attr_unique_id = f"{coordinator.unique_id}-{description.key}"
self._attr_is_on = False self._attr_is_on = False
async def async_update(self) -> None: @property
"""Update binary sensor state.""" def available(self) -> bool:
self._attr_is_on = self._server.online """Return binary sensor availability."""
return True
@property
def is_on(self) -> bool:
"""Return binary sensor state."""
return self.coordinator.last_update_success

View File

@ -1,15 +1,19 @@
"""Config flow for Minecraft Server integration.""" """Config flow for Minecraft Server integration."""
from contextlib import suppress from contextlib import suppress
import logging
from mcstatus import JavaServer
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow from homeassistant.config_entries import ConfigFlow
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.data_entry_flow import FlowResult from homeassistant.data_entry_flow import FlowResult
from . import MinecraftServer from . import helpers
from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Minecraft Server.""" """Handle a config flow for Minecraft Server."""
@ -52,16 +56,14 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: host, CONF_HOST: host,
CONF_PORT: port, CONF_PORT: port,
} }
server = MinecraftServer(self.hass, "dummy_unique_id", config_data) if await self._async_is_server_online(host, port):
await server.async_check_connection()
if not server.online:
# Host or port invalid or server not reachable.
errors["base"] = "cannot_connect"
else:
# Configuration data are available and no error was detected, # Configuration data are available and no error was detected,
# create configuration entry. # create configuration entry.
return self.async_create_entry(title=title, data=config_data) return self.async_create_entry(title=title, data=config_data)
# Host or port invalid or server not reachable.
errors["base"] = "cannot_connect"
# Show configuration form (default form in case of no user_input, # Show configuration form (default form in case of no user_input,
# form filled with user_input and eventually with errors otherwise). # form filled with user_input and eventually with errors otherwise).
return self._show_config_form(user_input, errors) return self._show_config_form(user_input, errors)
@ -85,3 +87,30 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
), ),
errors=errors, errors=errors,
) )
async def _async_is_server_online(self, host: str, port: int) -> bool:
"""Check server connection using a 'status' request and return result."""
# Check if host is a SRV record. If so, update server data.
if srv_record := await helpers.async_check_srv_record(host):
# Use extracted host and port from SRV record.
host = srv_record[CONF_HOST]
port = srv_record[CONF_PORT]
# Send a status request to the server.
server = JavaServer(host, port)
try:
await server.async_status()
return True
except OSError as error:
_LOGGER.debug(
(
"Error occurred while trying to check the connection to '%s:%s' -"
" OSError: %s"
),
host,
port,
error,
)
return False

View File

@ -28,8 +28,6 @@ MANUFACTURER = "Mojang AB"
SCAN_INTERVAL = 60 SCAN_INTERVAL = 60
SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}"
SRV_RECORD_PREFIX = "_minecraft._tcp" SRV_RECORD_PREFIX = "_minecraft._tcp"
UNIT_PLAYERS_MAX = "players" UNIT_PLAYERS_MAX = "players"

View File

@ -0,0 +1,93 @@
"""The Minecraft Server integration."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
from mcstatus.server import JavaServer
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import helpers
from .const import SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
@dataclass
class MinecraftServerData:
"""Representation of Minecraft Server data."""
latency: float
motd: str
players_max: int
players_online: int
players_list: list[str]
protocol_version: int
version: str
class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]):
"""Minecraft Server data update coordinator."""
_srv_record_checked = False
def __init__(
self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any]
) -> None:
"""Initialize coordinator instance."""
super().__init__(
hass=hass,
name=config_data[CONF_NAME],
logger=_LOGGER,
update_interval=timedelta(seconds=SCAN_INTERVAL),
)
# Server data
self.unique_id = unique_id
self._host = config_data[CONF_HOST]
self._port = config_data[CONF_PORT]
# 3rd party library instance
self._server = JavaServer(self._host, self._port)
async def _async_update_data(self) -> MinecraftServerData:
"""Get server data from 3rd party library and update properties."""
# Check once if host is a valid Minecraft SRV record.
if not self._srv_record_checked:
self._srv_record_checked = True
if srv_record := await helpers.async_check_srv_record(self._host):
# Overwrite host, port and 3rd party library instance
# with data extracted out of the SRV record.
self._host = srv_record[CONF_HOST]
self._port = srv_record[CONF_PORT]
self._server = JavaServer(self._host, self._port)
# Send status request to the server.
try:
status_response = await self._server.async_status()
except OSError as error:
raise UpdateFailed(error) from error
# Got answer to request, update properties.
players_list = []
if players := status_response.players.sample:
for player in players:
players_list.append(player.name)
players_list.sort()
return MinecraftServerData(
version=status_response.version.name,
protocol_version=status_response.version.protocol,
players_online=status_response.players.online,
players_max=status_response.players.max,
players_list=players_list,
latency=status_response.latency,
motd=status_response.motd.to_plain(),
)

View File

@ -1,52 +1,27 @@
"""Base entity for the Minecraft Server integration.""" """Base entity for the Minecraft Server integration."""
from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.entity import Entity
from . import MinecraftServer
from .const import DOMAIN, MANUFACTURER from .const import DOMAIN, MANUFACTURER
from .coordinator import MinecraftServerCoordinator
class MinecraftServerEntity(Entity): class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]):
"""Representation of a Minecraft Server base entity.""" """Representation of a Minecraft Server base entity."""
_attr_has_entity_name = True _attr_has_entity_name = True
_attr_should_poll = False
def __init__( def __init__(
self, self,
server: MinecraftServer, coordinator: MinecraftServerCoordinator,
) -> None: ) -> None:
"""Initialize base entity.""" """Initialize base entity."""
self._server = server super().__init__(coordinator)
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, server.unique_id)}, identifiers={(DOMAIN, coordinator.unique_id)},
manufacturer=MANUFACTURER, manufacturer=MANUFACTURER,
model=f"Minecraft Server ({server.data.version})", model=f"Minecraft Server ({coordinator.data.version})",
name=server.name, name=coordinator.name,
sw_version=str(server.data.protocol_version), sw_version=str(coordinator.data.protocol_version),
) )
self._disconnect_dispatcher: CALLBACK_TYPE | None = None
async def async_update(self) -> None:
"""Fetch data from the server."""
raise NotImplementedError()
async def async_added_to_hass(self) -> None:
"""Connect dispatcher to signal from server."""
self._disconnect_dispatcher = async_dispatcher_connect(
self.hass, self._server.signal_name, self._update_callback
)
async def async_will_remove_from_hass(self) -> None:
"""Disconnect dispatcher before removal."""
if self._disconnect_dispatcher:
self._disconnect_dispatcher()
@callback
def _update_callback(self) -> None:
"""Triggers update of properties after receiving signal from server."""
self.async_schedule_update_ha_state(force_refresh=True)

View File

@ -0,0 +1,38 @@
"""Helper functions of Minecraft Server integration."""
import logging
from typing import Any
import aiodns
from homeassistant.const import CONF_HOST, CONF_PORT
from .const import SRV_RECORD_PREFIX
_LOGGER = logging.getLogger(__name__)
async def async_check_srv_record(host: str) -> dict[str, Any] | None:
"""Check if the given host is a valid Minecraft SRV record."""
srv_record = None
try:
srv_query = await aiodns.DNSResolver().query(
host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV"
)
except aiodns.error.DNSError:
# 'host' is not a Minecraft SRV record.
pass
else:
# 'host' is a valid Minecraft SRV record, extract the data.
srv_record = {
CONF_HOST: srv_query[0].host,
CONF_PORT: srv_query[0].port,
}
_LOGGER.debug(
"'%s' is a valid Minecraft SRV record ('%s:%s')",
host,
srv_record[CONF_HOST],
srv_record[CONF_PORT],
)
return srv_record

View File

@ -8,11 +8,10 @@ from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTime from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from . import MinecraftServer, MinecraftServerData
from .const import ( from .const import (
ATTR_PLAYERS_LIST, ATTR_PLAYERS_LIST,
DOMAIN, DOMAIN,
@ -31,6 +30,7 @@ from .const import (
UNIT_PLAYERS_MAX, UNIT_PLAYERS_MAX,
UNIT_PLAYERS_ONLINE, UNIT_PLAYERS_ONLINE,
) )
from .coordinator import MinecraftServerCoordinator, MinecraftServerData
from .entity import MinecraftServerEntity from .entity import MinecraftServerEntity
@ -118,15 +118,14 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Minecraft Server sensor platform.""" """Set up the Minecraft Server sensor platform."""
server = hass.data[DOMAIN][config_entry.entry_id] coordinator = hass.data[DOMAIN][config_entry.entry_id]
# Add sensor entities. # Add sensor entities.
async_add_entities( async_add_entities(
[ [
MinecraftServerSensorEntity(server, description) MinecraftServerSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS for description in SENSOR_DESCRIPTIONS
], ]
True,
) )
@ -137,24 +136,27 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity):
def __init__( def __init__(
self, self,
server: MinecraftServer, coordinator: MinecraftServerCoordinator,
description: MinecraftServerSensorEntityDescription, description: MinecraftServerSensorEntityDescription,
) -> None: ) -> None:
"""Initialize sensor base entity.""" """Initialize sensor base entity."""
super().__init__(server) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{server.unique_id}-{description.key}" self._attr_unique_id = f"{coordinator.unique_id}-{description.key}"
self._update_properties()
@property @callback
def available(self) -> bool: def _handle_coordinator_update(self) -> None:
"""Return sensor availability.""" """Handle updated data from the coordinator."""
return self._server.online self._update_properties()
self.async_write_ha_state()
async def async_update(self) -> None: @callback
"""Update sensor state.""" def _update_properties(self) -> None:
self._attr_native_value = self.entity_description.value_fn(self._server.data) """Update sensor properties."""
self._attr_native_value = self.entity_description.value_fn(
if self.entity_description.attributes_fn: self.coordinator.data
self._attr_extra_state_attributes = self.entity_description.attributes_fn(
self._server.data
) )
if func := self.entity_description.attributes_fn:
self._attr_extra_state_attributes = func(self.coordinator.data)