diff --git a/.strict-typing b/.strict-typing index cca866871cce..c39d7c1f225f 100644 --- a/.strict-typing +++ b/.strict-typing @@ -350,6 +350,7 @@ homeassistant.components.letpot.* homeassistant.components.lg_infrared.* homeassistant.components.lg_tv_rs232.* homeassistant.components.libre_hardware_monitor.* +homeassistant.components.librenms.* homeassistant.components.lidarr.* homeassistant.components.liebherr.* homeassistant.components.lifx.* diff --git a/CODEOWNERS b/CODEOWNERS index 0821f468b449..e36a7f3ab530 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1038,6 +1038,8 @@ CLAUDE.md @home-assistant/core /tests/components/lg_tv_rs232/ @balloob /homeassistant/components/libre_hardware_monitor/ @Sab44 /tests/components/libre_hardware_monitor/ @Sab44 +/homeassistant/components/librenms/ @mib1185 +/tests/components/librenms/ @mib1185 /homeassistant/components/lichess/ @aryanhasgithub /tests/components/lichess/ @aryanhasgithub /homeassistant/components/lidarr/ @tkdrob diff --git a/homeassistant/components/librenms/__init__.py b/homeassistant/components/librenms/__init__.py new file mode 100644 index 000000000000..6ec24f2f3ad1 --- /dev/null +++ b/homeassistant/components/librenms/__init__.py @@ -0,0 +1,25 @@ +"""The LibreNMS integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import LibrenmsConfigEntry, LibrenmsDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: LibrenmsConfigEntry) -> bool: + """Set up LibreNMS from a config entry.""" + + coordinator = LibrenmsDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: LibrenmsConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/librenms/binary_sensor.py b/homeassistant/components/librenms/binary_sensor.py new file mode 100644 index 000000000000..683ac0596a25 --- /dev/null +++ b/homeassistant/components/librenms/binary_sensor.py @@ -0,0 +1,82 @@ +"""Binary sensor platform for the LibreNMS integration.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging +from typing import override + +from aiolibrenms.devices.models import LibrenmsDeviceInfo + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LibrenmsConfigEntry, LibrenmsDataUpdateCoordinator +from .entity import LibrenmsDeviceEntity + +_LOGGER = logging.getLogger(__name__) + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class LibrenmsDeviceBinarySensorEntityDescription(BinarySensorEntityDescription): + """Librenms device sensor entity description.""" + + value: Callable[[LibrenmsDeviceInfo], bool] + is_suitable: Callable[[LibrenmsDeviceInfo], bool] = lambda _: True + + +DEVICE_SENSOR_TYPES: tuple[LibrenmsDeviceBinarySensorEntityDescription, ...] = ( + LibrenmsDeviceBinarySensorEntityDescription( + key="status", + translation_key="status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + value=lambda data: data.status, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: LibrenmsConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add LibreNMS server state sensors.""" + coordinator = entry.runtime_data + async_add_entities( + LibrenmsDeviceBinarySensorEntity(coordinator, description, dev_id) + for description in DEVICE_SENSOR_TYPES + for dev_id, dev in coordinator.data.devices.items() + if description.is_suitable(dev) + ) + + +class LibrenmsDeviceBinarySensorEntity(LibrenmsDeviceEntity, BinarySensorEntity): + """Define Librenms sensor entity.""" + + entity_description: LibrenmsDeviceBinarySensorEntityDescription + + def __init__( + self, + coordinator: LibrenmsDataUpdateCoordinator, + description: LibrenmsDeviceBinarySensorEntityDescription, + device_id: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator, device_id) + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{device_id}_{description.key}" + ) + self.entity_description = description + + @property + @override + def is_on(self) -> bool: + """Return the value reported by the sensor.""" + return self.entity_description.value(self._data) diff --git a/homeassistant/components/librenms/config_flow.py b/homeassistant/components/librenms/config_flow.py new file mode 100644 index 000000000000..777d31837866 --- /dev/null +++ b/homeassistant/components/librenms/config_flow.py @@ -0,0 +1,125 @@ +"""Config flow for the LibreNMS integration.""" + +from collections.abc import Mapping +import logging +from typing import Any, override + +from aiolibrenms import Librenms +from aiolibrenms.const import CONNECT_ERRORS +from aiolibrenms.exceptions import LibrenmsUnauthenticatedError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DEFAULT_VERIFY_SSL, DOMAIN + + +class InvalidUrl(HomeAssistantError): + """Error to indicate invalid URL.""" + + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_API_KEY): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Required(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + } +) + + +def _parse_url(url: str) -> tuple[str, int, bool]: + """Parse the URL and return host, port, and ssl.""" + parsed_url = URL(url) + if ( + (host := parsed_url.host) is None + or (port := parsed_url.port) is None + or (scheme := parsed_url.scheme) is None + ): + raise InvalidUrl + return host, port, scheme == "https" + + +async def check_connection( + hass: HomeAssistant, host: str, port: int, ssl: bool, verify_ssl: bool, api_key: str +) -> None: + """Test connection.""" + session = async_get_clientsession(hass, verify_ssl) + lnms = Librenms(session, api_key, host, port, ssl) + await lnms.system.async_get_system_info() + + +class LibrenmsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for LibreNMS.""" + + VERSION = 1 + + _name: str + _current_data: Mapping[str, Any] + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + (host, port, ssl) = _parse_url(user_input[CONF_URL]) + except InvalidUrl: + errors[CONF_URL] = "invalid_url" + else: + self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port}) + try: + await check_connection( + self.hass, + host, + port, + ssl, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + except LibrenmsUnauthenticatedError: + errors["base"] = "invalid_auth" + except CONNECT_ERRORS: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=host, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: ssl, + CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_API_KEY: user_input[CONF_API_KEY], + }, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/librenms/const.py b/homeassistant/components/librenms/const.py new file mode 100644 index 000000000000..1d6a34b88c56 --- /dev/null +++ b/homeassistant/components/librenms/const.py @@ -0,0 +1,5 @@ +"""Constants for the LibreNMS integration.""" + +DOMAIN = "librenms" + +DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/librenms/coordinator.py b/homeassistant/components/librenms/coordinator.py new file mode 100644 index 000000000000..09785ab71f19 --- /dev/null +++ b/homeassistant/components/librenms/coordinator.py @@ -0,0 +1,107 @@ +"""Coordinator for the LibreNMS integration.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import override + +from aiolibrenms import Librenms +from aiolibrenms.const import CONNECT_ERRORS +from aiolibrenms.devices.models import LibrenmsDeviceInfo +from aiolibrenms.exceptions import LibrenmsUnauthenticatedError +from aiolibrenms.system.models import LibrenmsSystemInfo +from yarl import URL + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class LibrenmsData: + """Data class for storing data from the API.""" + + system: LibrenmsSystemInfo + devices: dict[int, LibrenmsDeviceInfo] + + +type LibrenmsConfigEntry = ConfigEntry[LibrenmsDataUpdateCoordinator] + + +class LibrenmsDataUpdateCoordinator(DataUpdateCoordinator[LibrenmsData]): + """Class to manage fetching LibreNMS data.""" + + config_entry: LibrenmsConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: LibrenmsConfigEntry) -> None: + """Initialize the data update coordinator.""" + self.api = Librenms( + async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]), + config_entry.data[CONF_API_KEY], + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.data[CONF_SSL], + ) + self.configuration_url = str( + URL.build( + scheme="https" if config_entry.data[CONF_SSL] else "http", + host=config_entry.data[CONF_HOST], + port=config_entry.data[CONF_PORT], + ) + ) + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(seconds=60), + ) + + @override + async def _async_setup(self) -> None: + """Handle setup of the coordinator.""" + try: + await self.api.system.async_get_system_info() + except LibrenmsUnauthenticatedError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err + except CONNECT_ERRORS as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + ) from err + + @override + async def _async_update_data(self) -> LibrenmsData: + """Update data via internal method.""" + try: + system = await self.api.system.async_get_system_info() + devices = await self.api.devices.async_get_devices() + except LibrenmsUnauthenticatedError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="auth_error", + ) from err + except CONNECT_ERRORS as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(err)}, + ) from err + + return LibrenmsData(system, {dev.device_id: dev for dev in devices}) diff --git a/homeassistant/components/librenms/entity.py b/homeassistant/components/librenms/entity.py new file mode 100644 index 000000000000..152d475df03a --- /dev/null +++ b/homeassistant/components/librenms/entity.py @@ -0,0 +1,55 @@ +"""Base entity for the LibreNMS integration.""" + +from typing import override + +from aiolibrenms.devices.models import LibrenmsDeviceInfo + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LibrenmsDataUpdateCoordinator + + +class LibrenmsDeviceEntity(CoordinatorEntity[LibrenmsDataUpdateCoordinator]): + """Define LibreNMS device base entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LibrenmsDataUpdateCoordinator, + device_id: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self.device_id = device_id + + identifier = f"{coordinator.config_entry.entry_id}_{self.device_id}" + sw_version = self._data.version + model = None + configuration_url = f"{coordinator.configuration_url}/device/{self.device_id}" + if self._data.os != "ping": + if sw_version and (feature := self._data.features) is not None: + sw_version += f" ({feature})" + model = self._data.hardware + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, identifier)}, + sw_version=sw_version, + configuration_url=configuration_url, + name=self._data.display, + model=model, + serial_number=self._data.serial, + ) + + @property + @override + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.device_id in self.coordinator.data.devices + + @property + def _data(self) -> LibrenmsDeviceInfo: + """Get DeviceInfo from coordinator.""" + return self.coordinator.data.devices[self.device_id] diff --git a/homeassistant/components/librenms/manifest.json b/homeassistant/components/librenms/manifest.json new file mode 100644 index 000000000000..d8f8b7a7b1dc --- /dev/null +++ b/homeassistant/components/librenms/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "librenms", + "name": "LibreNMS", + "codeowners": ["@mib1185"], + "config_flow": true, + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/librenms", + "integration_type": "service", + "iot_class": "local_polling", + "loggers": ["aiolibrenms"], + "quality_scale": "bronze", + "requirements": ["aiolibrenms==0.0.3"] +} diff --git a/homeassistant/components/librenms/quality_scale.yaml b/homeassistant/components/librenms/quality_scale.yaml new file mode 100644 index 000000000000..caed96e9b947 --- /dev/null +++ b/homeassistant/components/librenms/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-conditions: + status: exempt + comment: This integration does not have any conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: This integration does not have any triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No repair issues needed + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/librenms/strings.json b/homeassistant/components/librenms/strings.json new file mode 100644 index 000000000000..3544e152ec19 --- /dev/null +++ b/homeassistant/components/librenms/strings.json @@ -0,0 +1,50 @@ +{ + "common": { + "data_desc_api_key": "API key to connect to your LibreNMS instance.", + "data_desc_ssl_verify": "Whether to verify the SSL certificate when SSL encryption is used to connect to your LibreNMS instance.", + "data_desc_url": "The full URL of your LibreNMS instance." + }, + "config": { + "abort": { + "already_configured": "This LibreNMS instance is already configured." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_url": "The provided URL is invalid.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + }, + "data_description": { + "api_key": "[%key:component::librenms::common::data_desc_api_key%]", + "url": "[%key:component::librenms::common::data_desc_url%]", + "verify_ssl": "[%key:component::librenms::common::data_desc_ssl_verify%]" + } + } + } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + } + }, + "exceptions": { + "auth_error": { + "message": "Authentication failed, please update your API key" + }, + "cannot_connect": { + "message": "Cannot connect to your LibreNMS instance." + }, + "update_error": { + "message": "An error occurred while retrieving data from your LibreNMS instance: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index bde3ad8e7290..1cb7e868a583 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -432,6 +432,7 @@ FLOWS = { "lg_thinq", "lg_tv_rs232", "libre_hardware_monitor", + "librenms", "lichess", "lidarr", "liebherr", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ca5f74fe44a1..9b2dc08b7d3d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3916,6 +3916,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "librenms": { + "name": "LibreNMS", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_polling" + }, "lichess": { "name": "Lichess", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 87870119d26e..3e0f337d69a9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3258,6 +3258,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.librenms.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lidarr.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 4a65fd763b80..aa313619a91b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -323,6 +323,9 @@ aiokef==0.2.16 # homeassistant.components.rehlko aiokem==1.0.1 +# homeassistant.components.librenms +aiolibrenms==0.0.3 + # homeassistant.components.lichess aiolichess==1.3.0 diff --git a/tests/components/librenms/__init__.py b/tests/components/librenms/__init__.py new file mode 100644 index 000000000000..171e2539019c --- /dev/null +++ b/tests/components/librenms/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the LibreNMS integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/librenms/conftest.py b/tests/components/librenms/conftest.py new file mode 100644 index 000000000000..1bbf4b39e0b7 --- /dev/null +++ b/tests/components/librenms/conftest.py @@ -0,0 +1,70 @@ +"""Common fixtures for the LibreNMS tests.""" + +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, patch + +from aiolibrenms.devices import LibrenmsDevices +from aiolibrenms.system import LibrenmsSystem +import pytest + +from homeassistant.components.librenms.const import DOMAIN + +from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_DEVICES_DATA, MOCK_SYSTEM_DATA + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.librenms.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG_ENTRY_DATA, + title="librenms", + entry_id="01KXX1E2EMMSCDQ2K4A0C7JA9T", + ) + + +@pytest.fixture +def mock_librenms_system() -> AsyncMock: + """Mock the LibreNMS system api.""" + mock = AsyncMock(spec=LibrenmsSystem) + mock.async_get_system_info.return_value = MOCK_SYSTEM_DATA + return mock + + +@pytest.fixture +def mock_librenms_devices() -> AsyncMock: + """Mock the LibreNMS devices api.""" + mock = AsyncMock(spec=LibrenmsDevices) + mock.async_get_devices.return_value = MOCK_DEVICES_DATA + return mock + + +@pytest.fixture +async def mock_librenms( + mock_librenms_devices: AsyncMock, + mock_librenms_system: AsyncMock, +) -> AsyncGenerator[AsyncMock]: + """Mock the LibreNMS API.""" + with ( + patch( + "homeassistant.components.librenms.coordinator.Librenms", autospec=True + ) as mock_librenms, + patch( + "homeassistant.components.librenms.config_flow.Librenms", new=mock_librenms + ), + ): + client = mock_librenms.return_value + client.devices = mock_librenms_devices + client.system = mock_librenms_system + yield client diff --git a/tests/components/librenms/const.py b/tests/components/librenms/const.py new file mode 100644 index 000000000000..e4c6f4c07253 --- /dev/null +++ b/tests/components/librenms/const.py @@ -0,0 +1,41 @@ +"""Constants for the LibreNMS integration tests.""" + +from aiolibrenms.devices.models import LibrenmsDeviceInfo +from aiolibrenms.system.models import LibrenmsSystemInfo + +from homeassistant.components.librenms.const import DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_URL, + CONF_VERIFY_SSL, +) + +from tests.common import load_fixture + +MOCK_USER_DATA = { + CONF_URL: "https://librenms", + CONF_API_KEY: "abcdef0123456789", + CONF_VERIFY_SSL: True, +} + +MOCK_CONFIG_ENTRY_DATA = { + CONF_HOST: "librenms", + CONF_API_KEY: "abcdef0123456789", + CONF_PORT: 443, + CONF_SSL: True, + CONF_VERIFY_SSL: True, +} + +MOCK_SYSTEM_DATA = LibrenmsSystemInfo.from_json( + load_fixture("system_data.json", DOMAIN) +) + +MOCK_DEVICES_DATA = [ + LibrenmsDeviceInfo.from_json(load_fixture("device_1.json", DOMAIN)), + LibrenmsDeviceInfo.from_json(load_fixture("device_3.json", DOMAIN)), + LibrenmsDeviceInfo.from_json(load_fixture("device_13.json", DOMAIN)), + LibrenmsDeviceInfo.from_json(load_fixture("device_29.json", DOMAIN)), +] diff --git a/tests/components/librenms/fixtures/device_1.json b/tests/components/librenms/fixtures/device_1.json new file mode 100644 index 000000000000..8850a871e270 --- /dev/null +++ b/tests/components/librenms/fixtures/device_1.json @@ -0,0 +1,63 @@ +{ + "device_id": 1, + "inserted": "2023-09-16 11:29:40", + "hostname": "192.168.100.1", + "sysName": "sophosxg", + "display": "SophosXG", + "display_template": "SophosXG", + "ip": "192.168.100.1", + "overwrite_ip": "", + "community": "read-only", + "authlevel": null, + "authname": null, + "authpass": null, + "authalgo": null, + "cryptopass": null, + "cryptoalgo": null, + "snmpver": "v2c", + "port": 161, + "transport": "udp", + "timeout": null, + "retries": null, + "snmp_disable": 0, + "bgpLocalAs": null, + "sysObjectID": ".1.3.6.1.4.1.2604.5", + "snmpEngineID": "80 00 1F 88 80 BD 17 B9 64 41 DA 99 64", + "sysDescr": "Linux localhost 6.6.116 #1 SMP Fri Feb 27 22:12:13 CST 2026 x86_64", + "sysContact": "me", + "version": "22.0.1 MR-1-Build490", + "hardware": "SFVH_KV01_SFOS", + "features": null, + "location_id": 1, + "os": "sophos-xg", + "status": 1, + "status_reason": "", + "ignore": 0, + "disabled": 0, + "uptime": 2592289, + "agent_uptime": 0, + "last_polled": "2026-07-13 22:19:47", + "last_poll_attempted": null, + "last_polled_timetaken": 1.6974771022797, + "last_discovered_timetaken": 2.6829509735107, + "last_discovered": "2026-07-13 20:00:04", + "last_ping": "2026-07-13 22:20:03", + "last_ping_timetaken": 0.149, + "purpose": "", + "type": "appliance", + "serial": "C01yyyyyyxxxxxx", + "icon": "sophos-xg.png", + "poller_group": 0, + "override_sysLocation": 0, + "notes": null, + "port_association_mode": 1, + "max_depth": 2, + "disable_notify": 0, + "ignore_status": 0, + "mtu_status": 1, + "dependency_parent_id": "5", + "dependency_parent_hostname": "192.168.100.2", + "location": "Home", + "lat": null, + "lng": null +} diff --git a/tests/components/librenms/fixtures/device_13.json b/tests/components/librenms/fixtures/device_13.json new file mode 100644 index 000000000000..12bc964d2cb0 --- /dev/null +++ b/tests/components/librenms/fixtures/device_13.json @@ -0,0 +1,63 @@ +{ + "device_id": 13, + "inserted": "2023-09-23 19:45:29", + "hostname": "192.168.1.5", + "sysName": "homeassistant", + "display": "homeassistant", + "display_template": null, + "ip": "192.168.1.5", + "overwrite_ip": "", + "community": "read-only", + "authlevel": null, + "authname": null, + "authpass": null, + "authalgo": null, + "cryptopass": null, + "cryptoalgo": null, + "snmpver": "v2c", + "port": 161, + "transport": "udp", + "timeout": null, + "retries": null, + "snmp_disable": 0, + "bgpLocalAs": null, + "sysObjectID": ".1.3.6.1.4.1.8072.3.2.10", + "snmpEngineID": "80 00 1F 88 80 E7 73 4D 32 ED 2A 54 6A 00 00 00 \n00", + "sysDescr": "Linux homeassistant 6.18.37-haos #1 SMP PREEMPT_DYNAMIC Wed Jul 1 07:35:33 UTC 2026 x86_64 (Home Assistant OS 18.1)", + "sysContact": "me", + "version": "6.18.37-haos", + "hardware": "QEMU Standard PC (i440FX + PIIX, 1996)", + "features": "Home Assistant OS 18.1", + "location_id": 4, + "os": "linux", + "status": 1, + "status_reason": "", + "ignore": 0, + "disabled": 0, + "uptime": 72931, + "agent_uptime": 0, + "last_polled": "2026-07-13 22:17:06", + "last_poll_attempted": null, + "last_polled_timetaken": 1.6433110237122, + "last_discovered_timetaken": 2.8492691516876, + "last_discovered": "2026-07-13 20:00:10", + "last_ping": "2026-07-13 22:20:03", + "last_ping_timetaken": 0.772, + "purpose": "", + "type": "server", + "serial": null, + "icon": "linux.svg", + "poller_group": 0, + "override_sysLocation": 1, + "notes": null, + "port_association_mode": 1, + "max_depth": 2, + "disable_notify": 0, + "ignore_status": 0, + "mtu_status": 1, + "dependency_parent_id": "5", + "dependency_parent_hostname": "192.168.100.2", + "location": "Home", + "lat": null, + "lng": null +} diff --git a/tests/components/librenms/fixtures/device_29.json b/tests/components/librenms/fixtures/device_29.json new file mode 100644 index 000000000000..7076416bf5ae --- /dev/null +++ b/tests/components/librenms/fixtures/device_29.json @@ -0,0 +1,63 @@ +{ + "device_id": 29, + "inserted": "2024-11-13 10:47:15", + "hostname": "104.21.87.21", + "sysName": "firmware.esphome.io (104.21.87.21)", + "display": "firmware.esphome.io (104.21.87.21)", + "display_template": null, + "ip": "104.21.87.21", + "overwrite_ip": null, + "community": null, + "authlevel": null, + "authname": null, + "authpass": null, + "authalgo": null, + "cryptopass": null, + "cryptoalgo": null, + "snmpver": "v2c", + "port": 161, + "transport": "udp", + "timeout": null, + "retries": null, + "snmp_disable": 1, + "bgpLocalAs": null, + "sysObjectID": null, + "snmpEngineID": null, + "sysDescr": null, + "sysContact": null, + "version": null, + "hardware": "", + "features": null, + "location_id": null, + "os": "ping", + "status": 1, + "status_reason": "", + "ignore": 0, + "disabled": 0, + "uptime": null, + "agent_uptime": 0, + "last_polled": "2026-07-13 22:18:41", + "last_poll_attempted": null, + "last_polled_timetaken": 1.1394469738007, + "last_discovered_timetaken": 1.0503590106964, + "last_discovered": "2026-07-13 20:00:29", + "last_ping": "2026-07-13 22:20:03", + "last_ping_timetaken": 95.6, + "purpose": null, + "type": "", + "serial": null, + "icon": null, + "poller_group": 0, + "override_sysLocation": 0, + "notes": null, + "port_association_mode": 1, + "max_depth": 0, + "disable_notify": 0, + "ignore_status": 0, + "mtu_status": 1, + "dependency_parent_id": null, + "dependency_parent_hostname": null, + "location": null, + "lat": null, + "lng": null +} diff --git a/tests/components/librenms/fixtures/device_3.json b/tests/components/librenms/fixtures/device_3.json new file mode 100644 index 000000000000..10c04e20fae1 --- /dev/null +++ b/tests/components/librenms/fixtures/device_3.json @@ -0,0 +1,63 @@ +{ + "device_id": 3, + "inserted": "2023-09-16 11:34:51", + "hostname": "192.168.1.104", + "sysName": "printer", + "display": "Drucker", + "display_template": "Drucker", + "ip": "192.168.1.104", + "overwrite_ip": "", + "community": "read-only", + "authlevel": null, + "authname": null, + "authpass": null, + "authalgo": null, + "cryptopass": null, + "cryptoalgo": null, + "snmpver": "v2c", + "port": 161, + "transport": "udp", + "timeout": null, + "retries": null, + "snmp_disable": 0, + "bgpLocalAs": null, + "sysObjectID": ".1.3.6.1.4.1.641.52.71107331", + "snmpEngineID": "", + "sysDescr": "Lexmark MC2425adw version CXNZJ.250.038 kernel 6.6.86-yocto-standard All-N-1", + "sysContact": "me", + "version": "CXNZJ.250.038", + "hardware": "MC2425adw", + "features": null, + "location_id": 3, + "os": "lexmarkprinter", + "status": 1, + "status_reason": "", + "ignore": 0, + "disabled": 0, + "uptime": 15830868, + "agent_uptime": 0, + "last_polled": "2026-07-13 22:17:46", + "last_poll_attempted": null, + "last_polled_timetaken": 1.7405550479889, + "last_discovered_timetaken": 2.7769849300385, + "last_discovered": "2026-07-13 20:00:07", + "last_ping": "2026-07-13 22:20:03", + "last_ping_timetaken": 0.812, + "purpose": "", + "type": "printer", + "serial": "7529936145YFW", + "icon": "lexmark.svg", + "poller_group": 0, + "override_sysLocation": 0, + "notes": null, + "port_association_mode": 1, + "max_depth": 0, + "disable_notify": 1, + "ignore_status": 0, + "mtu_status": 1, + "dependency_parent_id": null, + "dependency_parent_hostname": null, + "location": "Home", + "lat": null, + "lng": null +} diff --git a/tests/components/librenms/fixtures/system_data.json b/tests/components/librenms/fixtures/system_data.json new file mode 100644 index 000000000000..0067c61c74a5 --- /dev/null +++ b/tests/components/librenms/fixtures/system_data.json @@ -0,0 +1,12 @@ +{ + "local_ver": "26.6.1", + "local_sha": "", + "local_date": "2026-06-18T09:10:36+02:00", + "local_branch": "", + "db_schema": "2026_06_09_000000_change_vsz_to_big_int_processes_table (390)", + "php_ver": "8.4.21", + "python_ver": "3.12.13", + "database_ver": "MariaDB 10.5.29-MariaDB-ubu2004", + "rrdtool_ver": "1.9.0", + "netsnmp_ver": "5.9.5.2" +} diff --git a/tests/components/librenms/snapshots/test_binary_sensor.ambr b/tests/components/librenms/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..4899c54b8ad1 --- /dev/null +++ b/tests/components/librenms/snapshots/test_binary_sensor.ambr @@ -0,0 +1,321 @@ +# serializer version: 1 +# name: test_sensors.8 + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_1', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'SFVH_KV01_SFOS', + 'model_id': None, + 'name': 'SophosXG', + 'name_by_user': None, + 'serial_number': 'C01yyyyyyxxxxxx', + 'sw_version': '22.0.1 MR-1-Build490', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/3', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_3', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'MC2425adw', + 'model_id': None, + 'name': 'Drucker', + 'name_by_user': None, + 'serial_number': '7529936145YFW', + 'sw_version': 'CXNZJ.250.038', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/13', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_13', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'QEMU Standard PC (i440FX + PIIX, 1996)', + 'model_id': None, + 'name': 'homeassistant', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': '6.18.37-haos (Home Assistant OS 18.1)', + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entry_id': , + 'config_subentry_id': , + 'configuration_url': 'https://librenms/device/29', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'librenms', + '01KXX1E2EMMSCDQ2K4A0C7JA9T_29', + ), + }), + 'labels': set({ + }), + 'manufacturer': None, + 'model': None, + 'model_id': None, + 'name': 'firmware.esphome.io (104.21.87.21)', + 'name_by_user': None, + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[binary_sensor.drucker_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.drucker_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_3_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.drucker_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'Drucker Status', + }), + 'context': , + 'entity_id': 'binary_sensor.drucker_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.firmware_esphome_io_104_21_87_21_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.firmware_esphome_io_104_21_87_21_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_29_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.firmware_esphome_io_104_21_87_21_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'firmware.esphome.io (104.21.87.21) Status', + }), + 'context': , + 'entity_id': 'binary_sensor.firmware_esphome_io_104_21_87_21_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.homeassistant_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.homeassistant_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_13_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.homeassistant_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'homeassistant Status', + }), + 'context': , + 'entity_id': 'binary_sensor.homeassistant_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_sensors[binary_sensor.sophosxg_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.sophosxg_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'librenms', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': '01KXX1E2EMMSCDQ2K4A0C7JA9T_1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.sophosxg_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'connectivity', + : 'SophosXG Status', + }), + 'context': , + 'entity_id': 'binary_sensor.sophosxg_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/librenms/test_binary_sensor.py b/tests/components/librenms/test_binary_sensor.py new file mode 100644 index 000000000000..79765a1bd16c --- /dev/null +++ b/tests/components/librenms/test_binary_sensor.py @@ -0,0 +1,36 @@ +"""Test the LibreNMS binary sensor platform.""" + +from unittest.mock import Mock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mock_librenms: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the LibreNMS binary sensor platform.""" + + with patch("homeassistant.components.librenms.PLATFORMS", [Platform.BINARY_SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert devices == snapshot diff --git a/tests/components/librenms/test_config_flow.py b/tests/components/librenms/test_config_flow.py new file mode 100644 index 000000000000..de9e4d3586c5 --- /dev/null +++ b/tests/components/librenms/test_config_flow.py @@ -0,0 +1,125 @@ +"""Test the LibreNMS config flow.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientError +from aiolibrenms.exceptions import LibrenmsUnauthenticatedError +import pytest + +from homeassistant.components.librenms.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .const import MOCK_CONFIG_ENTRY_DATA, MOCK_USER_DATA + +from tests.common import MockConfigEntry + + +async def test_step_user( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_librenms: Mock +) -> None: + """Test a user initiated config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "librenms" + assert result["data"] == MOCK_CONFIG_ENTRY_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + LibrenmsUnauthenticatedError({"message": "Unauthenticated."}), + "invalid_auth", + ), + (ClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_user_error_handling( + hass: HomeAssistant, mock_librenms: Mock, exception: Exception, error: str +) -> None: + """Test a user initiated config flow with errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_librenms.system.async_get_system_info.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_librenms.system.async_get_system_info.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_step_user_invalid_url(hass: HomeAssistant, mock_librenms: Mock) -> None: + """Test a user initiated config flow with errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**MOCK_USER_DATA, CONF_URL: "hts://invalid"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {CONF_URL: "invalid_url"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_already_configured( + hass: HomeAssistant, mock_librenms: Mock, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by user when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured"