From a2a62839bc5f9df482fea3ddd0996a970c0ad21f Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 19 Sep 2023 15:59:58 +0200 Subject: [PATCH] Add DataUpdateCoordinator to Minecraft Server (#100075) --- .coveragerc | 1 + .../components/minecraft_server/__init__.py | 221 ++---------------- .../minecraft_server/binary_sensor.py | 27 ++- .../minecraft_server/config_flow.py | 43 +++- .../components/minecraft_server/const.py | 2 - .../minecraft_server/coordinator.py | 93 ++++++++ .../components/minecraft_server/entity.py | 43 +--- .../components/minecraft_server/helpers.py | 38 +++ .../components/minecraft_server/sensor.py | 42 ++-- 9 files changed, 229 insertions(+), 281 deletions(-) create mode 100644 homeassistant/components/minecraft_server/coordinator.py create mode 100644 homeassistant/components/minecraft_server/helpers.py diff --git a/.coveragerc b/.coveragerc index 308546a5ebb..73ae1d1a466 100644 --- a/.coveragerc +++ b/.coveragerc @@ -735,6 +735,7 @@ omit = homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py homeassistant/components/minecraft_server/binary_sensor.py + homeassistant/components/minecraft_server/coordinator.py homeassistant/components/minecraft_server/entity.py homeassistant/components/minecraft_server/sensor.py homeassistant/components/minio/minio_helper.py diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index ee8bdbe2a3f..b7326735be9 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -1,31 +1,17 @@ """The Minecraft Server integration.""" from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import datetime, timedelta import logging from typing import Any -import aiodns -from mcstatus.server import JavaServer - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send import homeassistant.helpers.entity_registry as er -from homeassistant.helpers.event import async_track_time_interval -from .const import ( - DOMAIN, - KEY_LATENCY, - KEY_MOTD, - SCAN_INTERVAL, - SIGNAL_NAME_PREFIX, - SRV_RECORD_PREFIX, -) +from .const import DOMAIN, KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -34,19 +20,20 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """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( - "Creating server instance for '%s' (%s)", + "Creating coordinator instance for '%s' (%s)", entry.data[CONF_NAME], entry.data[CONF_HOST], ) - server = MinecraftServer(hass, config_entry_id, entry.data) - domain_data[config_entry_id] = server - await server.async_update() - server.start_periodic_update() + + # Create coordinator instance. + config_entry_id = entry.entry_id + 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. 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: """Unload Minecraft Server config entry.""" config_entry_id = config_entry.entry_id - server = hass.data[DOMAIN][config_entry_id] # 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. - server.stop_periodic_update() hass.data[DOMAIN].pop(config_entry_id) 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} - - -@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 diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 51978d388b6..0446e0a2d7c 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MinecraftServer from .const import DOMAIN, ICON_STATUS, KEY_STATUS +from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity @@ -36,15 +36,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """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. async_add_entities( [ - MinecraftServerBinarySensorEntity(server, description) + MinecraftServerBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS - ], - True, + ] ) @@ -55,15 +54,21 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, description: MinecraftServerBinarySensorEntityDescription, ) -> None: """Initialize binary sensor base entity.""" - super().__init__(server=server) + super().__init__(coordinator) 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 - async def async_update(self) -> None: - """Update binary sensor state.""" - self._attr_is_on = self._server.online + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return True + + @property + def is_on(self) -> bool: + """Return binary sensor state.""" + return self.coordinator.last_update_success diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index cdb345df55c..beacfde5b8e 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,15 +1,19 @@ """Config flow for Minecraft Server integration.""" from contextlib import suppress +import logging +from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from . import MinecraftServer +from . import helpers from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +_LOGGER = logging.getLogger(__name__) + class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" @@ -52,16 +56,14 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): CONF_HOST: host, CONF_PORT: port, } - server = MinecraftServer(self.hass, "dummy_unique_id", config_data) - await server.async_check_connection() - if not server.online: - # Host or port invalid or server not reachable. - errors["base"] = "cannot_connect" - else: + if await self._async_is_server_online(host, port): # Configuration data are available and no error was detected, # create configuration entry. 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, # form filled with user_input and eventually with errors otherwise). return self._show_config_form(user_input, errors) @@ -85,3 +87,30 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): ), 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 diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 5b59913c790..ea510c467a1 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -28,8 +28,6 @@ MANUFACTURER = "Mojang AB" SCAN_INTERVAL = 60 -SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" - SRV_RECORD_PREFIX = "_minecraft._tcp" UNIT_PLAYERS_MAX = "players" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py new file mode 100644 index 00000000000..6965759e734 --- /dev/null +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -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(), + ) diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 4702b42beb9..e7e91c7be86 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,52 +1,27 @@ """Base entity for the Minecraft Server integration.""" - -from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import MinecraftServer from .const import DOMAIN, MANUFACTURER +from .coordinator import MinecraftServerCoordinator -class MinecraftServerEntity(Entity): +class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): """Representation of a Minecraft Server base entity.""" _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, ) -> None: """Initialize base entity.""" - self._server = server + super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, server.unique_id)}, + identifiers={(DOMAIN, coordinator.unique_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({server.data.version})", - name=server.name, - sw_version=str(server.data.protocol_version), + model=f"Minecraft Server ({coordinator.data.version})", + name=coordinator.name, + 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) diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py new file mode 100644 index 00000000000..ac9ec52f679 --- /dev/null +++ b/homeassistant/components/minecraft_server/helpers.py @@ -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 diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index cb3be3e58d7..27749e5b60f 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -8,11 +8,10 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry 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.typing import StateType -from . import MinecraftServer, MinecraftServerData from .const import ( ATTR_PLAYERS_LIST, DOMAIN, @@ -31,6 +30,7 @@ from .const import ( UNIT_PLAYERS_MAX, UNIT_PLAYERS_ONLINE, ) +from .coordinator import MinecraftServerCoordinator, MinecraftServerData from .entity import MinecraftServerEntity @@ -118,15 +118,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """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. async_add_entities( [ - MinecraftServerSensorEntity(server, description) + MinecraftServerSensorEntity(coordinator, description) for description in SENSOR_DESCRIPTIONS - ], - True, + ] ) @@ -137,24 +136,27 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, ) -> None: """Initialize sensor base entity.""" - super().__init__(server) + super().__init__(coordinator) 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 - def available(self) -> bool: - """Return sensor availability.""" - return self._server.online + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_properties() + self.async_write_ha_state() - async def async_update(self) -> None: - """Update sensor state.""" - self._attr_native_value = self.entity_description.value_fn(self._server.data) + @callback + def _update_properties(self) -> None: + """Update sensor properties.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) - if self.entity_description.attributes_fn: - 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)