From c6a3fa30f09dac20c42c4348f5dd770bbe4a8f0b Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 10 Oct 2023 08:42:35 +0200 Subject: [PATCH] Add support for Minecraft Server Bedrock Edition (#100925) --- .coveragerc | 1 + .../components/minecraft_server/__init__.py | 35 +++-- .../components/minecraft_server/api.py | 134 ++++++++++++++++++ .../minecraft_server/binary_sensor.py | 7 +- .../minecraft_server/config_flow.py | 64 ++++----- .../minecraft_server/coordinator.py | 57 ++------ .../components/minecraft_server/entity.py | 11 +- .../components/minecraft_server/sensor.py | 75 +++++++++- .../components/minecraft_server/strings.json | 11 +- tests/components/minecraft_server/const.py | 40 ++++++ .../minecraft_server/test_config_flow.py | 119 +++++++++++++--- .../components/minecraft_server/test_init.py | 37 ++--- 12 files changed, 447 insertions(+), 144 deletions(-) create mode 100644 homeassistant/components/minecraft_server/api.py diff --git a/.coveragerc b/.coveragerc index 478454c18e8..a13959d0185 100644 --- a/.coveragerc +++ b/.coveragerc @@ -745,6 +745,7 @@ omit = homeassistant/components/mill/climate.py homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py + homeassistant/components/minecraft_server/api.py homeassistant/components/minecraft_server/binary_sensor.py homeassistant/components/minecraft_server/coordinator.py homeassistant/components/minecraft_server/entity.py diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 7f2b08c96ef..53324e6d5a4 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -4,14 +4,21 @@ from __future__ import annotations import logging from typing import Any -from mcstatus import JavaServer - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_TYPE, + Platform, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er +from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD from .coordinator import MinecraftServerCoordinator @@ -23,8 +30,20 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" + # Check and create API instance. + try: + api = await hass.async_add_executor_job( + MinecraftServer, + entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + entry.data[CONF_ADDRESS], + ) + except MinecraftServerAddressError as error: + raise ConfigEntryError( + f"Server address in configuration entry is invalid (error: {error})" + ) from error + # Create coordinator instance. - coordinator = MinecraftServerCoordinator(hass, entry) + coordinator = MinecraftServerCoordinator(hass, entry.data[CONF_NAME], api) await coordinator.async_config_entry_first_refresh() # Store coordinator instance. @@ -85,9 +104,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate config entry. try: address = config_data[CONF_HOST] - JavaServer.lookup(address) + MinecraftServer(MinecraftServerType.JAVA_EDITION, address) host_only_lookup_success = True - except ValueError as error: + except MinecraftServerAddressError as error: host_only_lookup_success = False _LOGGER.debug( "Hostname (without port) cannot be parsed (error: %s), trying again with port", @@ -97,8 +116,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if not host_only_lookup_success: try: address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" - JavaServer.lookup(address) - except ValueError as error: + MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + except MinecraftServerAddressError as error: _LOGGER.exception( "Can't migrate configuration entry due to error while parsing server address (error: %s), try again later", error, diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py new file mode 100644 index 00000000000..d0bd679def8 --- /dev/null +++ b/homeassistant/components/minecraft_server/api.py @@ -0,0 +1,134 @@ +"""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 + +_LOGGER = logging.getLogger(__name__) + + +@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 MinecraftServer: + """Minecraft Server wrapper class for 3rd party library mcstatus.""" + + _server: BedrockServer | JavaServer + + def __init__(self, server_type: MinecraftServerType, address: str) -> None: + """Initialize server instance.""" + try: + if server_type == MinecraftServerType.JAVA_EDITION: + self._server = JavaServer.lookup(address) + else: + self._server = BedrockServer.lookup(address) + except (ValueError, LifetimeTimeout) as error: + raise MinecraftServerAddressError( + f"{server_type} server address '{address}' is invalid (error: {error})" + ) from error + + _LOGGER.debug( + "%s server instance created with address '%s'", server_type, 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: + 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 + + try: + status_response = await self._server.async_status() + except OSError as error: + raise MinecraftServerConnectionError( + f"Fetching data from the server failed (error: {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 = [] + + if players := status_response.players.sample: + for player in players: + players_list.append(player.name) + 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, + ) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index e89fce2d7d5..520d7342b35 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -45,7 +45,7 @@ async def async_setup_entry( # Add binary sensor entities. async_add_entities( [ - MinecraftServerBinarySensorEntity(coordinator, description) + MinecraftServerBinarySensorEntity(coordinator, description, config_entry) for description in BINARY_SENSOR_DESCRIPTIONS ] ) @@ -60,11 +60,12 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit self, coordinator: MinecraftServerCoordinator, description: MinecraftServerBinarySensorEntityDescription, + config_entry: ConfigEntry, ) -> None: """Initialize binary sensor base entity.""" - super().__init__(coordinator) + super().__init__(coordinator, config_entry) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_is_on = False @property diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 527dfa1ed04..f064a4ac1ef 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,13 +1,13 @@ """Config flow for Minecraft Server integration.""" import logging -from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DEFAULT_NAME, DOMAIN DEFAULT_ADDRESS = "localhost:25565" @@ -27,10 +27,28 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: address = user_input[CONF_ADDRESS] - if await self._async_is_server_online(address): - # No error was detected, create configuration entry. - config_data = {CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address} - return self.async_create_entry(title=address, data=config_data) + # Prepare config entry data. + config_data = { + CONF_NAME: user_input[CONF_NAME], + CONF_ADDRESS: address, + } + + # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. + for server_type in MinecraftServerType: + try: + api = await self.hass.async_add_executor_job( + MinecraftServer, server_type, address + ) + except MinecraftServerAddressError: + pass + else: + if await api.async_is_online(): + config_data[CONF_TYPE] = server_type + return self.async_create_entry(title=address, data=config_data) + + _LOGGER.debug( + "Connection check to %s server '%s' failed", server_type, address + ) # Host or port invalid or server not reachable. errors["base"] = "cannot_connect" @@ -59,37 +77,3 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) - - async def _async_is_server_online(self, address: str) -> bool: - """Check server connection using a 'status' request and return result.""" - - # Parse and check server address. - try: - server = await JavaServer.async_lookup(address) - except ValueError as error: - _LOGGER.debug( - ( - "Error occurred while parsing server address '%s' -" - " ValueError: %s" - ), - address, - error, - ) - return False - - # Send a status request to the server. - 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" - ), - server.address.host, - server.address.port, - error, - ) - - return False diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 9b5ab1fbb43..f7a60318c64 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -1,77 +1,36 @@ """The Minecraft Server integration.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from mcstatus.server import JavaServer - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .api import MinecraftServer, MinecraftServerConnectionError, MinecraftServerData + SCAN_INTERVAL = timedelta(seconds=60) _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.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, name: str, api: MinecraftServer) -> None: """Initialize coordinator instance.""" - config_data = config_entry.data - self.unique_id = config_entry.entry_id + self._api = api super().__init__( hass=hass, - name=config_data[CONF_NAME], + name=name, logger=_LOGGER, update_interval=SCAN_INTERVAL, ) - try: - self._server = JavaServer.lookup(config_data[CONF_ADDRESS]) - except ValueError as error: - raise HomeAssistantError( - f"Address in configuration entry cannot be parsed (error: {error}), please remove this device and add it again" - ) from error - async def _async_update_data(self) -> MinecraftServerData: - """Get server data from 3rd party library and update properties.""" + """Get updated data from the server.""" try: - status_response = await self._server.async_status() - except OSError as error: + return await self._api.async_get_data() + except MinecraftServerConnectionError as error: raise UpdateFailed(error) from error - - 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 9bac71e0000..9a94fb4e168 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,8 +1,11 @@ """Base entity for the Minecraft Server integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .api import MinecraftServerType from .const import DOMAIN from .coordinator import MinecraftServerCoordinator @@ -17,13 +20,15 @@ class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): def __init__( self, coordinator: MinecraftServerCoordinator, + config_entry: ConfigEntry, ) -> None: """Initialize base entity.""" super().__init__(coordinator) + self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.unique_id)}, + identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({coordinator.data.version})", + model=f"Minecraft Server ({config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION)})", name=coordinator.name, - sw_version=str(coordinator.data.protocol_version), + sw_version=f"{coordinator.data.version} ({coordinator.data.protocol_version})", ) diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index efe534e0f92..e63649c9239 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,17 +7,21 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTime +from homeassistant.const import CONF_TYPE, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from .api import MinecraftServerData, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD -from .coordinator import MinecraftServerCoordinator, MinecraftServerData +from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity ATTR_PLAYERS_LIST = "players_list" +ICON_EDITION = "mdi:minecraft" +ICON_GAME_MODE = "mdi:cog" +ICON_MAP_NAME = "mdi:map" ICON_LATENCY = "mdi:signal" ICON_PLAYERS_MAX = "mdi:account-multiple" ICON_PLAYERS_ONLINE = "mdi:account-multiple" @@ -25,6 +29,9 @@ ICON_PROTOCOL_VERSION = "mdi:numeric" ICON_VERSION = "mdi:numeric" ICON_MOTD = "mdi:minecraft" +KEY_EDITION = "edition" +KEY_GAME_MODE = "game_mode" +KEY_MAP_NAME = "map_name" KEY_PLAYERS_MAX = "players_max" KEY_PLAYERS_ONLINE = "players_online" KEY_PROTOCOL_VERSION = "protocol_version" @@ -40,6 +47,7 @@ class MinecraftServerEntityDescriptionMixin: value_fn: Callable[[MinecraftServerData], StateType] attributes_fn: Callable[[MinecraftServerData], MutableMapping[str, Any]] | None + supported_server_types: list[MinecraftServerType] @dataclass @@ -69,6 +77,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_VERSION, value_fn=lambda data: data.version, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_PROTOCOL_VERSION, @@ -76,6 +88,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PROTOCOL_VERSION, value_fn=lambda data: data.protocol_version, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_PLAYERS_MAX, @@ -84,6 +100,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PLAYERS_MAX, value_fn=lambda data: data.players_max, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_LATENCY, @@ -93,6 +113,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_LATENCY, value_fn=lambda data: data.latency, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_MOTD, @@ -100,6 +124,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_MOTD, value_fn=lambda data: data.motd, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_PLAYERS_ONLINE, @@ -108,6 +136,40 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PLAYERS_ONLINE, value_fn=lambda data: data.players_online, attributes_fn=get_extra_state_attributes_players_list, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], + ), + MinecraftServerSensorEntityDescription( + key=KEY_EDITION, + translation_key=KEY_EDITION, + icon=ICON_EDITION, + value_fn=lambda data: data.edition, + attributes_fn=None, + supported_server_types=[ + MinecraftServerType.BEDROCK_EDITION, + ], + ), + MinecraftServerSensorEntityDescription( + key=KEY_GAME_MODE, + translation_key=KEY_GAME_MODE, + icon=ICON_GAME_MODE, + value_fn=lambda data: data.game_mode, + attributes_fn=None, + supported_server_types=[ + MinecraftServerType.BEDROCK_EDITION, + ], + ), + MinecraftServerSensorEntityDescription( + key=KEY_MAP_NAME, + translation_key=KEY_MAP_NAME, + icon=ICON_MAP_NAME, + value_fn=lambda data: data.map_name, + attributes_fn=None, + supported_server_types=[ + MinecraftServerType.BEDROCK_EDITION, + ], ), ] @@ -123,8 +185,10 @@ async def async_setup_entry( # Add sensor entities. async_add_entities( [ - MinecraftServerSensorEntity(coordinator, description) + MinecraftServerSensorEntity(coordinator, description, config_entry) for description in SENSOR_DESCRIPTIONS + if config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION) + in description.supported_server_types ] ) @@ -138,11 +202,12 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): self, coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, + config_entry: ConfigEntry, ) -> None: """Initialize sensor base entity.""" - super().__init__(coordinator) + super().__init__(coordinator, config_entry) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._update_properties() @callback diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index c5fe5b81d81..622a45a5aeb 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -11,7 +11,7 @@ } }, "error": { - "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. Also ensure that you are running at least version 1.7 of Minecraft Java Edition on your server." + "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version 1.7." } }, "entity": { @@ -38,6 +38,15 @@ }, "motd": { "name": "World message" + }, + "game_mode": { + "name": "Game mode" + }, + "map_name": { + "name": "Map name" + }, + "edition": { + "name": "Edition" } } } diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index c7eb0e4b096..c579461611e 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -1,11 +1,16 @@ """Constants for Minecraft Server integration tests.""" from mcstatus.motd import Motd from mcstatus.status_response import ( + BedrockStatusPlayers, + BedrockStatusResponse, + BedrockStatusVersion, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion, ) +from homeassistant.components.minecraft_server.api import MinecraftServerData + TEST_HOST = "mc.dummyserver.com" TEST_PORT = 25566 TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" @@ -32,3 +37,38 @@ TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( icon=None, latency=5, ) + +TEST_JAVA_DATA = MinecraftServerData( + latency=5, + motd="Dummy MOTD", + players_max=10, + players_online=3, + protocol_version=123, + version="Dummy Version", + players_list=["Player 1", "Player 2", "Player 3"], + edition=None, + game_mode=None, + map_name=None, +) + +TEST_BEDROCK_STATUS_RESPONSE = BedrockStatusResponse( + players=BedrockStatusPlayers(online=3, max=10), + version=BedrockStatusVersion(brand="MCPE", name="Dummy Version", protocol=123), + motd=Motd.parse("Dummy Description", bedrock=True), + latency=5, + gamemode="Dummy Game Mode", + map_name="Dummy Map Name", +) + +TEST_BEDROCK_DATA = MinecraftServerData( + latency=5, + motd="Dummy MOTD", + players_max=10, + players_online=3, + protocol_version=123, + version="Dummy Version", + players_list=None, + edition="Dummy Edition", + game_mode="Dummy Game Mode", + map_name="Dummy Map Name", +) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 88afa6576d5..cca6d5d21ac 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -2,15 +2,17 @@ from unittest.mock import patch -from mcstatus import JavaServer - +from homeassistant.components.minecraft_server.api import ( + MinecraftServerAddressError, + MinecraftServerType, +) from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT +from .const import TEST_ADDRESS USER_INPUT = { CONF_NAME: DEFAULT_NAME, @@ -28,11 +30,12 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_lookup_failed(hass: HomeAssistant) -> None: +async def test_address_validation_failed(hass: HomeAssistant) -> None: """Test error in case of a failed connection.""" with patch( - "mcstatus.server.JavaServer.async_lookup", - side_effect=ValueError, + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[MinecraftServerAddressError, MinecraftServerAddressError], + return_value=None, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -42,12 +45,16 @@ async def test_lookup_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_failed(hass: HomeAssistant) -> None: - """Test error in case of a failed connection.""" +async def test_java_connection_failed(hass: HomeAssistant) -> None: + """Test error in case of a failed connection to a Java Edition server.""" with patch( - "mcstatus.server.JavaServer.async_lookup", - return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), - ), patch("mcstatus.server.JavaServer.async_status", side_effect=OSError): + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[MinecraftServerAddressError, None], + return_value=None, + ), patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=False, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) @@ -56,14 +63,37 @@ async def test_connection_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_succeeded(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with a host name.""" +async def test_bedrock_connection_failed(hass: HomeAssistant) -> None: + """Test error in case of a failed connection to a Bedrock Edition server.""" with patch( - "mcstatus.server.JavaServer.async_lookup", - return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[None, MinecraftServerAddressError], + return_value=None, ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_java_connection_succeeded(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Java Edition server.""" + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[ + MinecraftServerAddressError, # async_step_user (Bedrock Edition) + None, # async_step_user (Java Edition) + None, # async_setup_entry + ], + return_value=None, + ), patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=True, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -73,3 +103,56 @@ async def test_connection_succeeded(hass: HomeAssistant) -> None: assert result["title"] == USER_INPUT[CONF_ADDRESS] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION + + +async def test_bedrock_connection_succeeded(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Bedrock Edition server.""" + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=None, + return_value=None, + ), patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_ADDRESS] + assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] + assert result["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION + + +async def test_recovery(hass: HomeAssistant) -> None: + """Test config flow recovery (successful connection after a failed connection).""" + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[MinecraftServerAddressError, MinecraftServerAddressError], + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=None, + return_value=None, + ), patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=USER_INPUT + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == USER_INPUT[CONF_ADDRESS] + assert result2["data"][CONF_NAME] == USER_INPUT[CONF_NAME] + assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result2["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 1e3679fb1e3..cc9730ef3df 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -1,16 +1,15 @@ """Tests for the Minecraft Server integration.""" from unittest.mock import patch -from mcstatus import JavaServer - from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.minecraft_server.api import MinecraftServerAddressError from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT +from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_DATA, TEST_PORT from tests.common import MockConfigEntry @@ -122,15 +121,16 @@ async def test_entry_migration(hass: HomeAssistant) -> None: # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", side_effect=[ - ValueError, - JavaServer(host=TEST_HOST, port=TEST_PORT), - JavaServer(host=TEST_HOST, port=TEST_PORT), + MinecraftServerAddressError, # async_migrate_entry + None, # async_migrate_entry + None, # async_setup_entry ], + return_value=None, ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, + "homeassistant.components.minecraft_server.api.MinecraftServer.async_get_data", + return_value=TEST_JAVA_DATA, ): assert await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() @@ -142,6 +142,7 @@ async def test_entry_migration(hass: HomeAssistant) -> None: CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, } + assert config_entry.version == 3 # Test migrated device entry. @@ -174,14 +175,15 @@ async def test_entry_migration_host_only(hass: HomeAssistant) -> None: # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", side_effect=[ - JavaServer(host=TEST_HOST, port=TEST_PORT), - JavaServer(host=TEST_HOST, port=TEST_PORT), + None, # async_migrate_entry + None, # async_setup_entry ], + return_value=None, ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, + "homeassistant.components.minecraft_server.api.MinecraftServer.async_get_data", + return_value=TEST_JAVA_DATA, ): assert await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() @@ -205,11 +207,12 @@ async def test_entry_migration_v3_failure(hass: HomeAssistant) -> None: # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", side_effect=[ - ValueError, - ValueError, + MinecraftServerAddressError, # async_migrate_entry + MinecraftServerAddressError, # async_migrate_entry ], + return_value=None, ): assert not await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done()