diff --git a/.coveragerc b/.coveragerc index 6e2ea5ba89bf30083947281aae2b1308beeed8cb..369aaa3b4e00490a61d380af2c4f741c5c992f17 100644 --- a/.coveragerc +++ b/.coveragerc @@ -425,6 +425,10 @@ omit = homeassistant/components/mikrotik/device_tracker.py homeassistant/components/mill/climate.py homeassistant/components/mill/const.py + homeassistant/components/minecraft_server/__init__.py + homeassistant/components/minecraft_server/binary_sensor.py + homeassistant/components/minecraft_server/const.py + homeassistant/components/minecraft_server/sensor.py homeassistant/components/minio/* homeassistant/components/mitemp_bt/sensor.py homeassistant/components/mjpeg/camera.py diff --git a/CODEOWNERS b/CODEOWNERS index 4078a45f990014b9fe742530eda321bb784340fa..c3f018ef83a38a33d0c15b5cad692057f1097f64 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -212,6 +212,7 @@ homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel homeassistant/components/mikrotik/* @engrbm87 homeassistant/components/mill/* @danielhiversen homeassistant/components/min_max/* @fabaff +homeassistant/components/minecraft_server/* @elmurato homeassistant/components/minio/* @tkislan homeassistant/components/mobile_app/* @robbiet480 homeassistant/components/modbus/* @adamchengtkc diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..789e4d8f1b83f927008c850fb795bf177cfb8c49 --- /dev/null +++ b/homeassistant/components/minecraft_server/__init__.py @@ -0,0 +1,273 @@ +"""The Minecraft Server integration.""" + +import asyncio +from datetime import datetime, timedelta +import logging +from typing import Any, Dict + +from mcstatus.server import MinecraftServer as MCStatus + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import DOMAIN, MANUFACTURER, SCAN_INTERVAL, SIGNAL_NAME_PREFIX + +PLATFORMS = ["binary_sensor", "sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the Minecraft Server component.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool: + """Set up Minecraft Server from a config entry.""" + domain_data = hass.data.setdefault(DOMAIN, {}) + + # Create and store server instance. + unique_id = config_entry.unique_id + _LOGGER.debug( + "Creating server instance for '%s' (host='%s', port=%s)", + config_entry.data[CONF_NAME], + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + ) + server = MinecraftServer(hass, unique_id, config_entry.data) + domain_data[unique_id] = server + await server.async_update() + server.start_periodic_update() + + # Set up platform(s). + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) + + return True + + +async def async_unload_entry( + hass: HomeAssistantType, config_entry: ConfigEntry +) -> bool: + """Unload Minecraft Server config entry.""" + unique_id = config_entry.unique_id + server = hass.data[DOMAIN][unique_id] + + # Unload platforms. + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(config_entry, platform) + for platform in PLATFORMS + ] + ) + + # Clean up. + server.stop_periodic_update() + hass.data[DOMAIN].pop(unique_id) + + return True + + +class MinecraftServer: + """Representation of a Minecraft server.""" + + # Private constants + _MAX_RETRIES_PING = 3 + _MAX_RETRIES_STATUS = 3 + + def __init__( + self, hass: HomeAssistantType, unique_id: str, config_data: ConfigType + ) -> 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 + + # 3rd party library instance + self._mc_status = MCStatus(self.host, self.port) + + # Data provided by 3rd party library + self.description = None + self.version = None + self.protocol_version = None + self.latency_time = None + self.players_online = None + self.players_max = None + self.players_list = None + + # Dispatcher signal name + self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" + + # Callback for stopping periodic update. + self._stop_periodic_update = 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.""" + self._stop_periodic_update() + + async def async_check_connection(self) -> None: + """Check server connection using a 'ping' request and store result.""" + try: + await self._hass.async_add_executor_job( + self._mc_status.ping, self._MAX_RETRIES_PING + ) + self.online = True + except OSError as error: + _LOGGER.debug( + "Error occurred while trying to ping the server - OSError: %s", error + ) + self.online = False + + async def async_update(self, now: datetime = 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 server lost") + elif not server_online_old and server_online: + _LOGGER.info("Connection to server (re-)established") + + # 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._hass.async_add_executor_job( + self._mc_status.status, self._MAX_RETRIES_STATUS + ) + + # Got answer to request, update properties. + self.description = status_response.description["text"] + self.version = status_response.version.name + self.protocol_version = status_response.version.protocol + self.players_online = status_response.players.online + self.players_max = status_response.players.max + self.latency_time = status_response.latency + self.players_list = [] + if status_response.players.sample is not None: + for player in status_response.players.sample: + self.players_list.append(player.name) + + # Inform user once about successful update if necessary. + if self._last_status_request_failed: + _LOGGER.info("Updating the server properties succeeded again") + self._last_status_request_failed = False + except OSError as error: + # No answer to request, set all properties to unknown. + self.description = None + self.version = None + self.protocol_version = None + self.players_online = None + self.players_max = None + self.latency_time = None + self.players_list = None + + # Inform user once about failed update if necessary. + if not self._last_status_request_failed: + _LOGGER.warning( + "Updating the server properties failed - OSError: %s", error, + ) + self._last_status_request_failed = True + + +class MinecraftServerEntity(Entity): + """Representation of a Minecraft Server base entity.""" + + def __init__( + self, server: MinecraftServer, type_name: str, icon: str, device_class: str + ) -> None: + """Initialize base entity.""" + self._server = server + self._name = f"{server.name} {type_name}" + self._icon = icon + self._unique_id = f"{self._server.unique_id}-{type_name}" + self._device_info = { + "identifiers": {(DOMAIN, self._server.unique_id)}, + "name": self._server.name, + "manufacturer": MANUFACTURER, + "model": f"Minecraft Server ({self._server.version})", + "sw_version": self._server.protocol_version, + } + self._device_class = device_class + self._device_state_attributes = None + self._disconnect_dispatcher = None + + @property + def name(self) -> str: + """Return name.""" + return self._name + + @property + def unique_id(self) -> str: + """Return unique ID.""" + return self._unique_id + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information.""" + return self._device_info + + @property + def device_class(self) -> str: + """Return device class.""" + return self._device_class + + @property + def icon(self) -> str: + """Return icon.""" + return self._icon + + @property + def should_poll(self) -> bool: + """Disable polling.""" + return False + + 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.""" + 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/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..cde2a4149007d84031da0f33df51c9a9da28b673 --- /dev/null +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -0,0 +1,47 @@ +"""The Minecraft Server binary sensor platform.""" + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + BinarySensorDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import MinecraftServer, MinecraftServerEntity +from .const import DOMAIN, ICON_STATUS, NAME_STATUS + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Minecraft Server binary sensor platform.""" + server = hass.data[DOMAIN][config_entry.unique_id] + + # Create entities list. + entities = [MinecraftServerStatusBinarySensor(server)] + + # Add binary sensor entities. + async_add_entities(entities, True) + + +class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorDevice): + """Representation of a Minecraft Server status binary sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize status binary sensor.""" + super().__init__( + server=server, + type_name=NAME_STATUS, + icon=ICON_STATUS, + device_class=DEVICE_CLASS_CONNECTIVITY, + ) + self._is_on = False + + @property + def is_on(self) -> bool: + """Return binary state.""" + return self._is_on + + async def async_update(self) -> None: + """Update status.""" + self._is_on = self._server.online diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..8c6049a2c1b5caf9fcfd6f855681cd56f62f664e --- /dev/null +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Minecraft Server integration.""" +from functools import partial +import ipaddress + +import getmac +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT + +from . import MinecraftServer +from .const import ( # pylint: disable=unused-import + DEFAULT_HOST, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, +) + + +class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Minecraft Server.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if user_input is not None: + # User inputs. + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + + unique_id = "" + + # Check if 'host' is a valid IP address and if so, get the MAC address. + ip_address = None + mac_address = None + try: + ip_address = ipaddress.ip_address(host) + except ValueError: + # Host is not a valid IP address. + pass + else: + # Host is a valid IP address. + if ip_address.version == 4: + # Address type is IPv4. + params = {"ip": host} + else: + # Address type is IPv6. + params = {"ip6": host} + mac_address = await self.hass.async_add_executor_job( + partial(getmac.get_mac_address, **params) + ) + + # Validate IP address via valid MAC address. + if ip_address is not None and mac_address is None: + errors["base"] = "invalid_ip" + # Validate port configuration (limit to user and dynamic port range). + elif (port < 1024) or (port > 65535): + errors["base"] = "invalid_port" + # Validate host and port via ping request to server. + else: + # Build unique_id. + if ip_address is not None: + # Since IP addresses can change and therefore are not allowed in a + # unique_id, fall back to the MAC address. + unique_id = f"{mac_address}-{port}" + else: + # Use host name in unique_id (host names should not change). + unique_id = f"{host}-{port}" + + # Abort in case the host was already configured before. + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Create server instance with configuration data and try pinging the server. + server = MinecraftServer(self.hass, unique_id, user_input) + 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, create configuration entry. + return self.async_create_entry( + title=f"{host}:{port}", data=user_input + ) + + # 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) + + def _show_config_form(self, user_input=None, errors=None): + """Show the setup form to the user.""" + if user_input is None: + user_input = {} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required( + CONF_HOST, default=user_input.get(CONF_HOST, DEFAULT_HOST) + ): vol.All(str, vol.Lower), + vol.Optional( + CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT) + ): int, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py new file mode 100644 index 0000000000000000000000000000000000000000..c3ab66154813a16ab04ef20cdd9d482c3c69221c --- /dev/null +++ b/homeassistant/components/minecraft_server/const.py @@ -0,0 +1,37 @@ +"""Constants for the Minecraft Server integration.""" + +ATTR_PLAYERS_LIST = "players_list" + +DEFAULT_HOST = "localhost" +DEFAULT_NAME = "Minecraft Server" +DEFAULT_PORT = 25565 + +DOMAIN = "minecraft_server" + +ICON_LATENCY_TIME = "mdi:signal" +ICON_PLAYERS_MAX = "mdi:account-multiple" +ICON_PLAYERS_ONLINE = "mdi:account-multiple" +ICON_PROTOCOL_VERSION = "mdi:numeric" +ICON_STATUS = "mdi:lan" +ICON_VERSION = "mdi:numeric" + +KEY_SERVERS = "servers" + +MANUFACTURER = "Mojang AB" + +NAME_LATENCY_TIME = "Latency Time" +NAME_PLAYERS_MAX = "Players Max" +NAME_PLAYERS_ONLINE = "Players Online" +NAME_PROTOCOL_VERSION = "Protocol Version" +NAME_STATUS = "Status" +NAME_VERSION = "Version" + +SCAN_INTERVAL = 60 + +SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" + +UNIT_LATENCY_TIME = "ms" +UNIT_PLAYERS_MAX = "players" +UNIT_PLAYERS_ONLINE = "players" +UNIT_PROTOCOL_VERSION = None +UNIT_VERSION = None diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..1dda76dee772ef18688c49484e5d2c0d9714090d --- /dev/null +++ b/homeassistant/components/minecraft_server/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "minecraft_server", + "name": "Minecraft Server", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/minecraft_server", + "requirements": ["getmac==0.8.1", "mcstatus==2.3.0"], + "dependencies": [], + "codeowners": ["@elmurato"], + "quality_scale": "silver" +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..0b37a7d979b7dc2d372d16c79457e71926e0be74 --- /dev/null +++ b/homeassistant/components/minecraft_server/sensor.py @@ -0,0 +1,177 @@ +"""The Minecraft Server sensor platform.""" + +import logging +from typing import Any, Dict + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import MinecraftServer, MinecraftServerEntity +from .const import ( + ATTR_PLAYERS_LIST, + DOMAIN, + ICON_LATENCY_TIME, + ICON_PLAYERS_MAX, + ICON_PLAYERS_ONLINE, + ICON_PROTOCOL_VERSION, + ICON_VERSION, + NAME_LATENCY_TIME, + NAME_PLAYERS_MAX, + NAME_PLAYERS_ONLINE, + NAME_PROTOCOL_VERSION, + NAME_VERSION, + UNIT_LATENCY_TIME, + UNIT_PLAYERS_MAX, + UNIT_PLAYERS_ONLINE, + UNIT_PROTOCOL_VERSION, + UNIT_VERSION, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up the Minecraft Server sensor platform.""" + server = hass.data[DOMAIN][config_entry.unique_id] + + # Create entities list. + entities = [ + MinecraftServerVersionSensor(server), + MinecraftServerProtocolVersionSensor(server), + MinecraftServerLatencyTimeSensor(server), + MinecraftServerPlayersOnlineSensor(server), + MinecraftServerPlayersMaxSensor(server), + ] + + # Add sensor entities. + async_add_entities(entities, True) + + +class MinecraftServerSensorEntity(MinecraftServerEntity): + """Representation of a Minecraft Server sensor base entity.""" + + def __init__( + self, + server: MinecraftServer, + type_name: str, + icon: str = None, + unit: str = None, + device_class: str = None, + ) -> None: + """Initialize sensor base entity.""" + super().__init__(server, type_name, icon, device_class) + self._state = None + self._unit = unit + + @property + def available(self) -> bool: + """Return sensor availability.""" + return self._server.online + + @property + def state(self) -> Any: + """Return sensor state.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return sensor measurement unit.""" + return self._unit + + +class MinecraftServerVersionSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server version sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize version sensor.""" + super().__init__( + server=server, type_name=NAME_VERSION, icon=ICON_VERSION, unit=UNIT_VERSION + ) + + async def async_update(self) -> None: + """Update version.""" + self._state = self._server.version + + +class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server protocol version sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize protocol version sensor.""" + super().__init__( + server=server, + type_name=NAME_PROTOCOL_VERSION, + icon=ICON_PROTOCOL_VERSION, + unit=UNIT_PROTOCOL_VERSION, + ) + + async def async_update(self) -> None: + """Update protocol version.""" + self._state = self._server.protocol_version + + +class MinecraftServerLatencyTimeSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server latency time sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize latency time sensor.""" + super().__init__( + server=server, + type_name=NAME_LATENCY_TIME, + icon=ICON_LATENCY_TIME, + unit=UNIT_LATENCY_TIME, + ) + + async def async_update(self) -> None: + """Update latency time.""" + self._state = self._server.latency_time + + +class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server online players sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize online players sensor.""" + super().__init__( + server=server, + type_name=NAME_PLAYERS_ONLINE, + icon=ICON_PLAYERS_ONLINE, + unit=UNIT_PLAYERS_ONLINE, + ) + + async def async_update(self) -> None: + """Update online players state and device state attributes.""" + self._state = self._server.players_online + + device_state_attributes = None + players_list = self._server.players_list + + if players_list is not None: + if len(players_list) != 0: + device_state_attributes = {ATTR_PLAYERS_LIST: self._server.players_list} + + self._device_state_attributes = device_state_attributes + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return players list in device state attributes.""" + return self._device_state_attributes + + +class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): + """Representation of a Minecraft Server maximum number of players sensor.""" + + def __init__(self, server: MinecraftServer) -> None: + """Initialize maximum number of players sensor.""" + super().__init__( + server=server, + type_name=NAME_PLAYERS_MAX, + icon=ICON_PLAYERS_MAX, + unit=UNIT_PLAYERS_MAX, + ) + + async def async_update(self) -> None: + """Update maximum number of players.""" + self._state = self._server.players_max diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json new file mode 100644 index 0000000000000000000000000000000000000000..7743d940be671400dd74c1830d0d6804f7114821 --- /dev/null +++ b/homeassistant/components/minecraft_server/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "title": "Minecraft Server", + "step": { + "user": { + "title": "Link your Minecraft Server", + "description": "Set up your Minecraft Server instance to allow monitoring.", + "data": { + "name": "Name", + "host": "Host", + "port": "Port" + } + } + }, + "error": { + "invalid_port": "Port must be in range from 1024 to 65535. Please correct it and try again.", + "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server.", + "invalid_ip": "IP address is invalid (MAC address could not be determined). Please correct it and try again." + }, + "abort": { + "already_configured": "Host is already configured." + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 83f7d4cfcfa7f016c331a7ce1b0e500d1d07b1fe..4872b08e9fc86b4adeff93f00d92f41b435c72f0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -57,6 +57,7 @@ FLOWS = [ "met", "meteo_france", "mikrotik", + "minecraft_server", "mobile_app", "mqtt", "neato", diff --git a/requirements_all.txt b/requirements_all.txt index d83f0884def3e4fc8df4bea90932dd48b8ded28d..a0c76d3ab22e8f64d6c0fe1cd07f2292069f3c95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -585,6 +585,7 @@ georss_qld_bushfire_alert_client==0.3 # homeassistant.components.braviatv # homeassistant.components.huawei_lte # homeassistant.components.kef +# homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker getmac==0.8.1 @@ -837,6 +838,9 @@ maxcube-api==0.1.0 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.minecraft_server +mcstatus==2.3.0 + # homeassistant.components.message_bird messagebird==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8d873b2fd64e6660a9c904fffa50cba3da1104f..33942e1b244e0a061eac791e7125730777fad61d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -210,6 +210,7 @@ georss_qld_bushfire_alert_client==0.3 # homeassistant.components.braviatv # homeassistant.components.huawei_lte # homeassistant.components.kef +# homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker getmac==0.8.1 @@ -301,6 +302,9 @@ luftdaten==0.6.3 # homeassistant.components.mythicbeastsdns mbddns==0.1.2 +# homeassistant.components.minecraft_server +mcstatus==2.3.0 + # homeassistant.components.meteo_france meteofrance==0.3.7 diff --git a/tests/components/minecraft_server/__init__.py b/tests/components/minecraft_server/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..36a1bb3f69d2ed2151924daf62036d7356508678 --- /dev/null +++ b/tests/components/minecraft_server/__init__.py @@ -0,0 +1 @@ +"""Tests for the Minecraft Server integration.""" diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py new file mode 100644 index 0000000000000000000000000000000000000000..30626fbdcb02805a7149a054d7b79f8b1f689a05 --- /dev/null +++ b/tests/components/minecraft_server/test_config_flow.py @@ -0,0 +1,194 @@ +"""Test the Minecraft Server config flow.""" + +from asynctest import patch +from mcstatus.pinger import PingResponse + +from homeassistant.components.minecraft_server.const import ( + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry + +STATUS_RESPONSE_RAW = { + "description": {"text": "Dummy Description"}, + "version": {"name": "Dummy Version", "protocol": 123}, + "players": { + "online": 3, + "max": 10, + "sample": [ + {"name": "Player 1", "id": "1"}, + {"name": "Player 2", "id": "2"}, + {"name": "Player 3", "id": "3"}, + ], + }, +} + +USER_INPUT = { + CONF_NAME: DEFAULT_NAME, + CONF_HOST: "mc.dummyserver.com", + CONF_PORT: DEFAULT_PORT, +} + +USER_INPUT_IPV4 = { + CONF_NAME: DEFAULT_NAME, + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, +} + +USER_INPUT_IPV6 = { + CONF_NAME: DEFAULT_NAME, + CONF_HOST: "::ffff:0101:0101", + CONF_PORT: DEFAULT_PORT, +} + +USER_INPUT_PORT_TOO_SMALL = { + CONF_NAME: DEFAULT_NAME, + CONF_HOST: "mc.dummyserver.com", + CONF_PORT: 1023, +} + +USER_INPUT_PORT_TOO_LARGE = { + CONF_NAME: DEFAULT_NAME, + CONF_HOST: "mc.dummyserver.com", + CONF_PORT: 65536, +} + + +async def test_show_config_form(hass: HomeAssistantType) -> None: + """Test if initial configuration form is shown.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_invalid_ip(hass: HomeAssistantType) -> None: + """Test error in case of an invalid IP address.""" + with patch("getmac.get_mac_address", return_value=None): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_ip"} + + +async def test_same_host(hass: HomeAssistantType) -> None: + """Test abort in case of same host name.""" + unique_id = f"{USER_INPUT[CONF_HOST]}-{USER_INPUT[CONF_PORT]}" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, unique_id=unique_id, data=USER_INPUT + ) + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_port_too_small(hass: HomeAssistantType) -> None: + """Test error in case of a too small port.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_SMALL + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_port"} + + +async def test_port_too_large(hass: HomeAssistantType) -> None: + """Test error in case of a too large port.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_LARGE + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_port"} + + +async def test_connection_failed(hass: HomeAssistantType) -> None: + """Test error in case of a failed connection.""" + with patch("mcstatus.server.MinecraftServer.ping", side_effect=OSError): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_connection_succeeded_with_host(hass: HomeAssistantType) -> None: + """Test config entry in case of a successful connection with a host name.""" + with patch("mcstatus.server.MinecraftServer.ping", return_value=50): + with patch( + "mcstatus.server.MinecraftServer.status", + return_value=PingResponse(STATUS_RESPONSE_RAW), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"{USER_INPUT[CONF_HOST]}:{USER_INPUT[CONF_PORT]}" + assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] + assert result["data"][CONF_HOST] == USER_INPUT[CONF_HOST] + assert result["data"][CONF_PORT] == USER_INPUT[CONF_PORT] + + +async def test_connection_succeeded_with_ip4(hass: HomeAssistantType) -> None: + """Test config entry in case of a successful connection with an IPv4 address.""" + with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"): + with patch("mcstatus.server.MinecraftServer.ping", return_value=50): + with patch( + "mcstatus.server.MinecraftServer.status", + return_value=PingResponse(STATUS_RESPONSE_RAW), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT_IPV4[CONF_HOST]}:{USER_INPUT_IPV4[CONF_PORT]}" + ) + assert result["data"][CONF_NAME] == USER_INPUT_IPV4[CONF_NAME] + assert result["data"][CONF_HOST] == USER_INPUT_IPV4[CONF_HOST] + assert result["data"][CONF_PORT] == USER_INPUT_IPV4[CONF_PORT] + + +async def test_connection_succeeded_with_ip6(hass: HomeAssistantType) -> None: + """Test config entry in case of a successful connection with an IPv6 address.""" + with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"): + with patch("mcstatus.server.MinecraftServer.ping", return_value=50): + with patch( + "mcstatus.server.MinecraftServer.status", + return_value=PingResponse(STATUS_RESPONSE_RAW), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6 + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert ( + result["title"] + == f"{USER_INPUT_IPV6[CONF_HOST]}:{USER_INPUT_IPV6[CONF_PORT]}" + ) + assert result["data"][CONF_NAME] == USER_INPUT_IPV6[CONF_NAME] + assert result["data"][CONF_HOST] == USER_INPUT_IPV6[CONF_HOST] + assert result["data"][CONF_PORT] == USER_INPUT_IPV6[CONF_PORT]