Add Minecraft Server Integration (#30992)

* Add Minecraft Server integration

* Add unit test for config flow

* Fixed some review findings and increased unit test coverage

* Fixed docstrings of new test cases

* Removed unnecessary debug log messages

* Added unique IDs and device infos and removed duplicate name validation

* Attempt to fix unit test on CI

* Return state OFF instead of UNAVAILABLE in case connection to server drops

* Added property decorator to server properties, even less debug messages, improved sensor dispatcher connection and other review findings fixed

* Moved special property handling to sensors, fixed name confusion in sensor entity, switch to HA const for scan_interval, simplified building players list string

* Improved periodic update, speeded up unit tests

* Added type hints, added callback decorator to entity update callback, added const.py to unit test exclusions

* Changed state sensor to binary sensor, removed empty unit test file, added constants for icons and units

* Let HA handle unknown state, check for None in description and players list sensor

* Removed periods at end of log messages, removed constant for default host

* Updated requirements_test_pre_commit.txt, fixed codespell findings

* Use localhost as default host

* Removed passing hass to entities, moved log message from init, moved host lower to vol, use proper patch library, patch library instead of own code

* Replaced server properties with global instance attributes, removed config option scan_interval, switch back to async_track_time_interval

* Removed description and players list sensors, added players list as state attributes to online players sensor, raise OSError instead of deprecated IOError, other minor review findings fixed

* Use MAC address for unique_id in case of an IP address as host, added getmac to manifest.json, added invalid_ip to strings.json, added new test cases for changes in config_flow, replace all IOError's with OSError, other review findings fixed

* Removed double assignment

* Call get_mac_address async safe

* Handle unavailable and unknown states to reach silver quality scale, added quality scale to manifest.json
This commit is contained in:
elmurato
2020-02-08 09:28:35 +01:00
committed by GitHub
parent 5483de7e25
commit 699d6ad658
14 changed files with 893 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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,
)

View File

@ -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

View File

@ -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"
}

View File

@ -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

View File

@ -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."
}
}
}

View File

@ -57,6 +57,7 @@ FLOWS = [
"met",
"meteo_france",
"mikrotik",
"minecraft_server",
"mobile_app",
"mqtt",
"neato",

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the Minecraft Server integration."""

View File

@ -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]