core/homeassistant/components/minecraft_server/api.py

179 lines
5.8 KiB
Python

"""API for the Minecraft Server integration."""
from dataclasses import dataclass
from enum import StrEnum
import logging
from dns.resolver import LifetimeTimeout
from mcstatus import BedrockServer, JavaServer
from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
LOOKUP_TIMEOUT: float = 10
DATA_UPDATE_TIMEOUT: float = 10
DATA_UPDATE_RETRIES: int = 3
@dataclass
class MinecraftServerData:
"""Representation of Minecraft Server data."""
# Common data
latency: float
motd: str
players_max: int
players_online: int
protocol_version: int
version: str
# Data available only in 'Java Edition'
players_list: list[str] | None = None
# Data available only in 'Bedrock Edition'
edition: str | None = None
game_mode: str | None = None
map_name: str | None = None
class MinecraftServerType(StrEnum):
"""Enumeration of Minecraft Server types."""
BEDROCK_EDITION = "Bedrock Edition"
JAVA_EDITION = "Java Edition"
class MinecraftServerAddressError(Exception):
"""Raised when the input address is invalid."""
class MinecraftServerConnectionError(Exception):
"""Raised when no data can be fechted from the server."""
class MinecraftServerNotInitializedError(Exception):
"""Raised when APIs are used although server instance is not initialized yet."""
class MinecraftServer:
"""Minecraft Server wrapper class for 3rd party library mcstatus."""
_server: BedrockServer | JavaServer | None
def __init__(
self, hass: HomeAssistant, server_type: MinecraftServerType, address: str
) -> None:
"""Initialize server instance."""
self._server = None
self._hass = hass
self._server_type = server_type
self._address = address
async def async_initialize(self) -> None:
"""Perform async initialization of server instance."""
try:
if self._server_type == MinecraftServerType.JAVA_EDITION:
self._server = await JavaServer.async_lookup(self._address)
else:
self._server = await self._hass.async_add_executor_job(
BedrockServer.lookup, self._address
)
except (ValueError, LifetimeTimeout) as error:
raise MinecraftServerAddressError(
f"Lookup of '{self._address}' failed: {self._get_error_message(error)}"
) from error
self._server.timeout = DATA_UPDATE_TIMEOUT
_LOGGER.debug(
"Initialized %s server instance with address '%s'",
self._server_type,
self._address,
)
async def async_is_online(self) -> bool:
"""Check if the server is online, supporting both Java and Bedrock Edition servers."""
try:
await self.async_get_data()
except (
MinecraftServerConnectionError,
MinecraftServerNotInitializedError,
) as error:
_LOGGER.debug(
"Connection check of %s server failed: %s",
self._server_type,
self._get_error_message(error),
)
return False
return True
async def async_get_data(self) -> MinecraftServerData:
"""Get updated data from the server, supporting both Java and Bedrock Edition servers."""
status_response: BedrockStatusResponse | JavaStatusResponse
if self._server is None:
raise MinecraftServerNotInitializedError(
f"Server instance with address '{self._address}' is not initialized"
)
try:
status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES)
except OSError as error:
raise MinecraftServerConnectionError(
f"Status request to '{self._address}' failed: {self._get_error_message(error)}"
) from error
if isinstance(status_response, JavaStatusResponse):
data = self._extract_java_data(status_response)
else:
data = self._extract_bedrock_data(status_response)
return data
def _extract_java_data(
self, status_response: JavaStatusResponse
) -> MinecraftServerData:
"""Extract Java Edition server data out of status response."""
players_list: list[str] = []
if players := status_response.players.sample:
players_list.extend(player.name for player in players)
players_list.sort()
return MinecraftServerData(
latency=status_response.latency,
motd=status_response.motd.to_plain(),
players_max=status_response.players.max,
players_online=status_response.players.online,
protocol_version=status_response.version.protocol,
version=status_response.version.name,
players_list=players_list,
)
def _extract_bedrock_data(
self, status_response: BedrockStatusResponse
) -> MinecraftServerData:
"""Extract Bedrock Edition server data out of status response."""
return MinecraftServerData(
latency=status_response.latency,
motd=status_response.motd.to_plain(),
players_max=status_response.players.max,
players_online=status_response.players.online,
protocol_version=status_response.version.protocol,
version=status_response.version.name,
edition=status_response.version.brand,
game_mode=status_response.gamemode,
map_name=status_response.map_name,
)
def _get_error_message(self, error: BaseException) -> str:
"""Get error message of an exception."""
if not str(error):
# Fallback to error type in case of an empty error message.
return repr(error)
return str(error)