diff --git a/.coveragerc b/.coveragerc index 05f1ed64c33..61dccdb2d8b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -347,6 +347,9 @@ omit = homeassistant/components/firmata/sensor.py homeassistant/components/firmata/switch.py homeassistant/components/fitbit/* + homeassistant/components/fivem/__init__.py + homeassistant/components/fivem/binary_sensor.py + homeassistant/components/fivem/sensor.py homeassistant/components/fixer/sensor.py homeassistant/components/fjaraskupan/__init__.py homeassistant/components/fjaraskupan/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 7e56031c2bf..b78d12939a8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -287,6 +287,8 @@ homeassistant/components/fireservicerota/* @cyberjunky tests/components/fireservicerota/* @cyberjunky homeassistant/components/firmata/* @DaAwesomeP tests/components/firmata/* @DaAwesomeP +homeassistant/components/fivem/* @Sander0542 +tests/components/fivem/* @Sander0542 homeassistant/components/fixer/* @fabaff homeassistant/components/fjaraskupan/* @elupus tests/components/fjaraskupan/* @elupus diff --git a/homeassistant/components/fivem/__init__.py b/homeassistant/components/fivem/__init__.py new file mode 100644 index 00000000000..4079b74a598 --- /dev/null +++ b/homeassistant/components/fivem/__init__.py @@ -0,0 +1,185 @@ +"""The FiveM integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from fivem import FiveM, FiveMServerOfflineError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import ( + ATTR_PLAYERS_LIST, + ATTR_RESOURCES_LIST, + DOMAIN, + MANUFACTURER, + NAME_PLAYERS_MAX, + NAME_PLAYERS_ONLINE, + NAME_RESOURCES, + NAME_STATUS, + SCAN_INTERVAL, +) + +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up FiveM from a config entry.""" + _LOGGER.debug( + "Create FiveM server instance for '%s:%s'", + entry.data[CONF_HOST], + entry.data[CONF_PORT], + ) + + try: + coordinator = FiveMDataUpdateCoordinator(hass, entry.data, entry.entry_id) + await coordinator.initialize() + except FiveMServerOfflineError as err: + raise ConfigEntryNotReady from err + + await coordinator.async_config_entry_first_refresh() + entry.async_on_unload(entry.add_update_listener(update_listener)) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener.""" + await hass.config_entries.async_reload(entry.entry_id) + + +class FiveMDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching FiveM data.""" + + def __init__(self, hass: HomeAssistant, config_data, unique_id: str) -> None: + """Initialize server instance.""" + self._hass = hass + + self.unique_id = unique_id + self.server = None + self.version = None + self.gamename: str | None = None + + self.server_name = config_data[CONF_NAME] + self.host = config_data[CONF_HOST] + self.port = config_data[CONF_PORT] + self.online = False + + self._fivem = FiveM(self.host, self.port) + + update_interval = timedelta(seconds=SCAN_INTERVAL) + _LOGGER.debug("Data will be updated every %s", update_interval) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def initialize(self) -> None: + """Initialize the FiveM server.""" + info = await self._fivem.get_info_raw() + self.server = info["server"] + self.version = info["version"] + self.gamename = info["vars"]["gamename"] + + async def _async_update_data(self) -> dict[str, Any]: + """Get server data from 3rd party library and update properties.""" + was_online = self.online + + try: + server = await self._fivem.get_server() + self.online = True + except FiveMServerOfflineError: + self.online = False + + if was_online and not self.online: + _LOGGER.warning("Connection to '%s:%s' lost", self.host, self.port) + elif not was_online and self.online: + _LOGGER.info("Connection to '%s:%s' (re-)established", self.host, self.port) + + if self.online: + players_list: list[str] = [] + for player in server.players: + players_list.append(player.name) + players_list.sort() + + resources_list = server.resources + resources_list.sort() + + return { + NAME_PLAYERS_ONLINE: len(players_list), + NAME_PLAYERS_MAX: server.max_players, + NAME_RESOURCES: len(resources_list), + NAME_STATUS: self.online, + ATTR_PLAYERS_LIST: players_list, + ATTR_RESOURCES_LIST: resources_list, + } + + raise UpdateFailed + + +@dataclass +class FiveMEntityDescription(EntityDescription): + """Describes FiveM entity.""" + + extra_attrs: list[str] | None = None + + +class FiveMEntity(CoordinatorEntity): + """Representation of a FiveM base entity.""" + + coordinator: FiveMDataUpdateCoordinator + entity_description: FiveMEntityDescription + + def __init__( + self, + coordinator: FiveMDataUpdateCoordinator, + description: FiveMEntityDescription, + ) -> None: + """Initialize base entity.""" + super().__init__(coordinator) + self.entity_description = description + + self._attr_name = f"{self.coordinator.server_name} {description.name}" + self._attr_unique_id = f"{self.coordinator.unique_id}-{description.key}".lower() + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer=MANUFACTURER, + model=self.coordinator.server, + name=self.coordinator.server_name, + sw_version=self.coordinator.version, + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the extra attributes of the sensor.""" + if self.entity_description.extra_attrs is None: + return None + + return { + attr: self.coordinator.data[attr] + for attr in self.entity_description.extra_attrs + } diff --git a/homeassistant/components/fivem/binary_sensor.py b/homeassistant/components/fivem/binary_sensor.py new file mode 100644 index 00000000000..2e9ea834799 --- /dev/null +++ b/homeassistant/components/fivem/binary_sensor.py @@ -0,0 +1,52 @@ +"""The FiveM binary sensor platform.""" +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FiveMEntity, FiveMEntityDescription +from .const import DOMAIN, ICON_STATUS, NAME_STATUS + + +class FiveMBinarySensorEntityDescription( + BinarySensorEntityDescription, FiveMEntityDescription +): + """Describes FiveM binary sensor entity.""" + + +BINARY_SENSORS: tuple[FiveMBinarySensorEntityDescription, ...] = ( + FiveMBinarySensorEntityDescription( + key=NAME_STATUS, + name=NAME_STATUS, + icon=ICON_STATUS, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the FiveM binary sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [FiveMSensorEntity(coordinator, description) for description in BINARY_SENSORS] + ) + + +class FiveMSensorEntity(FiveMEntity, BinarySensorEntity): + """Representation of a FiveM sensor base entity.""" + + entity_description: FiveMBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return the state of the sensor.""" + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/fivem/config_flow.py b/homeassistant/components/fivem/config_flow.py new file mode 100644 index 00000000000..792d5f1b025 --- /dev/null +++ b/homeassistant/components/fivem/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for FiveM integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from fivem import FiveM, FiveMServerOfflineError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 30120 + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect.""" + + fivem = FiveM(data[CONF_HOST], data[CONF_PORT]) + info = await fivem.get_info_raw() + + gamename = info.get("vars")["gamename"] + if gamename is None or gamename != "gta5": + raise InvalidGamenameError + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for FiveM.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + await validate_input(self.hass, user_input) + except FiveMServerOfflineError: + errors["base"] = "cannot_connect" + except InvalidGamenameError: + errors["base"] = "invalid_gamename" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry(title=user_input[CONF_NAME], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class InvalidGamenameError(Exception): + """Handle errors in the gamename from the api.""" diff --git a/homeassistant/components/fivem/const.py b/homeassistant/components/fivem/const.py new file mode 100644 index 00000000000..5907aa7300e --- /dev/null +++ b/homeassistant/components/fivem/const.py @@ -0,0 +1,24 @@ +"""Constants for the FiveM integration.""" + +ATTR_PLAYERS_LIST = "players_list" +ATTR_RESOURCES_LIST = "resources_list" + +DOMAIN = "fivem" + +ICON_PLAYERS_MAX = "mdi:account-multiple" +ICON_PLAYERS_ONLINE = "mdi:account-multiple" +ICON_RESOURCES = "mdi:playlist-check" +ICON_STATUS = "mdi:lan" + +MANUFACTURER = "Cfx.re" + +NAME_PLAYERS_MAX = "Players Max" +NAME_PLAYERS_ONLINE = "Players Online" +NAME_RESOURCES = "Resources" +NAME_STATUS = "Status" + +SCAN_INTERVAL = 60 + +UNIT_PLAYERS_MAX = "players" +UNIT_PLAYERS_ONLINE = "players" +UNIT_RESOURCES = "resources" diff --git a/homeassistant/components/fivem/manifest.json b/homeassistant/components/fivem/manifest.json new file mode 100644 index 00000000000..4a18df0fc95 --- /dev/null +++ b/homeassistant/components/fivem/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "fivem", + "name": "FiveM", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/fivem", + "requirements": [ + "fivem-api==0.1.2" + ], + "codeowners": [ + "@Sander0542" + ], + "iot_class": "local_polling" +} \ No newline at end of file diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py new file mode 100644 index 00000000000..31e23565a6f --- /dev/null +++ b/homeassistant/components/fivem/sensor.py @@ -0,0 +1,78 @@ +"""The FiveM sensor platform.""" +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FiveMEntity, FiveMEntityDescription +from .const import ( + ATTR_PLAYERS_LIST, + ATTR_RESOURCES_LIST, + DOMAIN, + ICON_PLAYERS_MAX, + ICON_PLAYERS_ONLINE, + ICON_RESOURCES, + NAME_PLAYERS_MAX, + NAME_PLAYERS_ONLINE, + NAME_RESOURCES, + UNIT_PLAYERS_MAX, + UNIT_PLAYERS_ONLINE, + UNIT_RESOURCES, +) + + +@dataclass +class FiveMSensorEntityDescription(SensorEntityDescription, FiveMEntityDescription): + """Describes FiveM sensor entity.""" + + +SENSORS: tuple[FiveMSensorEntityDescription, ...] = ( + FiveMSensorEntityDescription( + key=NAME_PLAYERS_MAX, + name=NAME_PLAYERS_MAX, + icon=ICON_PLAYERS_MAX, + native_unit_of_measurement=UNIT_PLAYERS_MAX, + ), + FiveMSensorEntityDescription( + key=NAME_PLAYERS_ONLINE, + name=NAME_PLAYERS_ONLINE, + icon=ICON_PLAYERS_ONLINE, + native_unit_of_measurement=UNIT_PLAYERS_ONLINE, + extra_attrs=[ATTR_PLAYERS_LIST], + ), + FiveMSensorEntityDescription( + key=NAME_RESOURCES, + name=NAME_RESOURCES, + icon=ICON_RESOURCES, + native_unit_of_measurement=UNIT_RESOURCES, + extra_attrs=[ATTR_RESOURCES_LIST], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the FiveM sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + # Add sensor entities. + async_add_entities( + [FiveMSensorEntity(coordinator, description) for description in SENSORS] + ) + + +class FiveMSensorEntity(FiveMEntity, SensorEntity): + """Representation of a FiveM sensor base entity.""" + + entity_description: FiveMSensorEntityDescription + + @property + def native_value(self) -> Any: + """Return the state of the sensor.""" + return self.coordinator.data[self.entity_description.key] diff --git a/homeassistant/components/fivem/strings.json b/homeassistant/components/fivem/strings.json new file mode 100644 index 00000000000..2949dfb58ad --- /dev/null +++ b/homeassistant/components/fivem/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + } + } + }, + "error": { + "cannot_connect": "Failed to connect. Please check the host and port and try again. Also ensure that you are running the latest FiveM server.", + "invalid_gamename": "The api of the game you are trying to connect to is not a FiveM game." + }, + "abort": { + "already_configured": "FiveM server is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fivem/translations/en.json b/homeassistant/components/fivem/translations/en.json new file mode 100644 index 00000000000..a81ce1e1ce5 --- /dev/null +++ b/homeassistant/components/fivem/translations/en.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_configured": "FiveM server is already configured" + }, + "error": { + "cannot_connect": "Failed to connect. Please check the host and port and try again. Also ensure that you are running the latest FiveM server.", + "invalid_gamename": "The api of the game you are trying to connect to is not a FiveM game." + }, + "step": { + "user": { + "data": { + "host": "Host", + "name": "Name", + "port": "Port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 061dcd3a0ad..2aab7c81481 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -95,6 +95,7 @@ FLOWS = [ "ezviz", "faa_delays", "fireservicerota", + "fivem", "fjaraskupan", "flick_electric", "flipr", diff --git a/requirements_all.txt b/requirements_all.txt index 0e49d5e7a5a..31994aa6489 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -671,6 +671,9 @@ fints==1.0.1 # homeassistant.components.fitbit fitbit==0.3.1 +# homeassistant.components.fivem +fivem-api==0.1.2 + # homeassistant.components.fixer fixerio==1.0.0a0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a057ba0fb3..df3f191abda 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -420,6 +420,9 @@ faadelays==0.0.7 # homeassistant.components.feedreader feedparser==6.0.2 +# homeassistant.components.fivem +fivem-api==0.1.2 + # homeassistant.components.fjaraskupan fjaraskupan==1.0.2 diff --git a/tests/components/fivem/__init__.py b/tests/components/fivem/__init__.py new file mode 100644 index 00000000000..019f9b82913 --- /dev/null +++ b/tests/components/fivem/__init__.py @@ -0,0 +1 @@ +"""Tests for the FiveM integration.""" diff --git a/tests/components/fivem/test_config_flow.py b/tests/components/fivem/test_config_flow.py new file mode 100644 index 00000000000..7af6e0fca48 --- /dev/null +++ b/tests/components/fivem/test_config_flow.py @@ -0,0 +1,146 @@ +"""Test the FiveM config flow.""" +from unittest.mock import patch + +from fivem import FiveMServerOfflineError + +from homeassistant import config_entries +from homeassistant.components.fivem.config_flow import DEFAULT_PORT +from homeassistant.components.fivem.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +USER_INPUT = { + CONF_NAME: "Dummy Server", + CONF_HOST: "fivem.dummyserver.com", + CONF_PORT: DEFAULT_PORT, +} + + +def __mock_fivem_info_success(): + return { + "resources": [ + "fivem", + "monitor", + ], + "server": "FXServer-dummy v0.0.0.DUMMY linux", + "vars": { + "gamename": "gta5", + }, + "version": 123456789, + } + + +def __mock_fivem_info_invalid(): + return { + "plugins": [ + "sample", + ], + "data": { + "gamename": "gta5", + }, + } + + +def __mock_fivem_info_invalid_gamename(): + info = __mock_fivem_info_success() + info["vars"]["gamename"] = "redm" + + return info + + +async def test_show_config_form(hass: HomeAssistant) -> None: + """Test if initial configuration form is shown.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "fivem.fivem.FiveM.get_info_raw", + return_value=__mock_fivem_info_success(), + ), patch( + "homeassistant.components.fivem.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == USER_INPUT[CONF_NAME] + assert result2["data"] == USER_INPUT + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "fivem.fivem.FiveM.get_info_raw", + side_effect=FiveMServerOfflineError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_invalid(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "fivem.fivem.FiveM.get_info_raw", + return_value=__mock_fivem_info_invalid(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_invalid_gamename(hass: HomeAssistant) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "fivem.fivem.FiveM.get_info_raw", + return_value=__mock_fivem_info_invalid_gamename(), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "invalid_gamename"}