Add LibreNMS integration (#176825)

This commit is contained in:
Michael
2026-08-26 16:21:10 +02:00
committed by GitHub
parent 600d546d80
commit e4cb2722b7
26 changed files with 1433 additions and 0 deletions
+1
View File
@@ -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.*
Generated
+2
View File
@@ -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
@@ -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)
@@ -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)
@@ -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
)
@@ -0,0 +1,5 @@
"""Constants for the LibreNMS integration."""
DOMAIN = "librenms"
DEFAULT_VERIFY_SSL = False
@@ -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})
@@ -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]
@@ -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"]
}
@@ -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
@@ -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}"
}
}
}
+1
View File
@@ -432,6 +432,7 @@ FLOWS = {
"lg_thinq",
"lg_tv_rs232",
"libre_hardware_monitor",
"librenms",
"lichess",
"lidarr",
"liebherr",
@@ -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",
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+13
View File
@@ -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()
+70
View File
@@ -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
+41
View File
@@ -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)),
]
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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"
}
@@ -0,0 +1,321 @@
# serializer version: 1
# name: test_sensors.8
list([
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': 'https://librenms/device/1',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': 'https://librenms/device/3',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': 'https://librenms/device/13',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'configuration_url': 'https://librenms/device/29',
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Status',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'connectivity',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Drucker Status',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.drucker_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Status',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'connectivity',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'firmware.esphome.io (104.21.87.21) Status',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.firmware_esphome_io_104_21_87_21_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_sensors[binary_sensor.homeassistant_status-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Status',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'connectivity',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'homeassistant Status',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.homeassistant_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_sensors[binary_sensor.sophosxg_status-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'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': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Status',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'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({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'connectivity',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'SophosXG Status',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.sophosxg_status',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
@@ -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
@@ -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"