Raise error on deprecated Libre Hardware Monitor version (#181928)

This commit is contained in:
Sab44
2026-09-11 11:26:22 +02:00
committed by GitHub
parent 77cae88e67
commit 458b11ee9b
8 changed files with 161 additions and 110 deletions
@@ -4,11 +4,7 @@ import logging
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
from .coordinator import (
@@ -82,21 +78,6 @@ async def async_setup_entry(
lhm_coordinator = LibreHardwareMonitorCoordinator(hass, config_entry)
await lhm_coordinator.async_config_entry_first_refresh()
if lhm_coordinator.data.is_deprecated_version:
issue_id = f"deprecated_api_{config_entry.entry_id}"
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
breaks_in_ha_version="2026.9.0",
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_api",
translation_placeholders={
"lhm_releases_url": "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases"
},
)
config_entry.runtime_data = lhm_coordinator
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
@@ -10,6 +10,7 @@ from librehardwaremonitor_api import (
LibreHardwareMonitorNoDevicesError,
LibreHardwareMonitorUnauthorizedError,
)
from librehardwaremonitor_api.model import LibreHardwareMonitorData
import voluptuous as vol
from homeassistant.config_entries import (
@@ -39,7 +40,9 @@ REAUTH_SCHEMA = vol.Schema(
)
async def _validate_connection(user_input: dict[str, Any]) -> str:
async def _validate_connection(
user_input: dict[str, Any],
) -> LibreHardwareMonitorData:
"""Ensure a connection can be established."""
api = LibreHardwareMonitorClient(
host=user_input[CONF_HOST],
@@ -48,7 +51,7 @@ async def _validate_connection(user_input: dict[str, Any]) -> str:
password=user_input.get(CONF_PASSWORD),
)
return (await api.get_data()).computer_name
return await api.get_data()
class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -73,7 +76,7 @@ class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN):
self._async_abort_entries_match(user_input)
try:
computer_name = await _validate_connection(user_input)
lhm_data = await _validate_connection(user_input)
except LibreHardwareMonitorConnectionError as exception:
_LOGGER.error(exception)
errors["base"] = "cannot_connect"
@@ -84,14 +87,17 @@ class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN):
except LibreHardwareMonitorNoDevicesError:
errors["base"] = "no_devices"
else:
return self.async_create_entry(
title=(
f"{computer_name}"
f" ({user_input[CONF_HOST]}"
f":{user_input[CONF_PORT]})"
),
data=user_input,
)
if lhm_data.is_deprecated_version:
errors["base"] = "deprecated_version"
else:
return self.async_create_entry(
title=(
f"{lhm_data.computer_name}"
f" ({user_input[CONF_HOST]}"
f":{user_input[CONF_PORT]})"
),
data=user_input,
)
return self.async_show_form(
step_id="user",
@@ -123,7 +129,7 @@ class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN):
**user_input,
}
try:
computer_name = await _validate_connection(data)
lhm_data = await _validate_connection(data)
except LibreHardwareMonitorConnectionError as exception:
_LOGGER.error(exception)
errors["base"] = "cannot_connect"
@@ -132,17 +138,20 @@ class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN):
except LibreHardwareMonitorNoDevicesError:
errors["base"] = "no_devices"
else:
if self.source == SOURCE_REAUTH:
if lhm_data.is_deprecated_version:
errors["base"] = "deprecated_version"
elif self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
entry=reauth_entry, # type: ignore[arg-type]
data_updates=user_input,
)
# the initial connection was unauthorized,
# now we can create the config entry
return self.async_create_entry(
title=f"{computer_name} ({self._host}:{self._port})",
data=data,
)
else:
# the initial connection was unauthorized,
# now we can create the config entry
return self.async_create_entry(
title=f"{lhm_data.computer_name} ({self._host}:{self._port})",
data=data,
)
return self.async_show_form(
step_id="reauth_confirm",
@@ -16,11 +16,11 @@ from librehardwaremonitor_api.model import (
LibreHardwareMonitorData,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -67,7 +67,6 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
for device in device_entries
if device.identifiers and device.name
}
self._is_deprecated_version: bool | None = None
@override
async def _async_update_data(self) -> LibreHardwareMonitorData:
@@ -83,12 +82,14 @@ class LibreHardwareMonitorCoordinator(DataUpdateCoordinator[LibreHardwareMonitor
except LibreHardwareMonitorNoDevicesError as err:
raise UpdateFailed("No sensor data available, will retry") from err
# Check whether user has upgraded LHM from a deprecated
# version while the integration is running
if self._is_deprecated_version and not lhm_data.is_deprecated_version:
# Clear deprecation issue
ir.async_delete_issue(self.hass, DOMAIN, f"deprecated_api_{self._entry_id}")
self._is_deprecated_version = lhm_data.is_deprecated_version
if lhm_data.is_deprecated_version:
if self.config_entry.state is ConfigEntryState.LOADED:
# if user downgrades while HA is running, reload integration to surface ConfigEntryError
self.hass.config_entries.async_schedule_reload(self._entry_id)
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="deprecated_version",
)
await self._async_handle_changes_in_devices(
dict(lhm_data.main_device_ids_and_names)
@@ -1,10 +1,14 @@
{
"common": {
"deprecated_version": "Your version of Libre Hardware Monitor is no longer supported. Please update to version 0.9.5 or later."
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"deprecated_version": "[%key:component::libre_hardware_monitor::common::deprecated_version%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "[%key:common::config_flow::abort::no_devices_found%]"
},
@@ -33,10 +37,9 @@
}
}
},
"issues": {
"deprecated_api": {
"description": "Your version of Libre Hardware Monitor is deprecated and may not provide stable sensor data. To fix this issue:\n\n1. Download version 0.9.5 or later from {lhm_releases_url}\n2. Close Libre Hardware Monitor on your computer\n3. Install or extract the new version and start Libre Hardware Monitor again (you might have to re-enable the remote web server)\n4. Home Assistant will detect the new version and this issue will clear automatically",
"title": "Deprecated Libre Hardware Monitor version"
"exceptions": {
"deprecated_version": {
"message": "[%key:component::libre_hardware_monitor::common::deprecated_version%]"
}
}
}
@@ -1,6 +1,7 @@
"""Common fixtures for the LibreHardwareMonitor tests."""
from collections.abc import Generator
from dataclasses import replace
from unittest.mock import AsyncMock, patch
from librehardwaremonitor_api.parser import LibreHardwareMonitorParser
@@ -84,3 +85,12 @@ def mock_lhm_client() -> Generator[AsyncMock]:
client.get_data.return_value = test_data
yield client
@pytest.fixture
def mock_deprecated_lhm_client(mock_lhm_client: AsyncMock) -> AsyncMock:
"""Mock a LibreHardwareMonitor client reporting a deprecated version."""
mock_lhm_client.get_data.return_value = replace(
mock_lhm_client.get_data.return_value, is_deprecated_version=True
)
return mock_lhm_client
@@ -1,5 +1,6 @@
"""Test the LibreHardwareMonitor config flow."""
from dataclasses import replace
from unittest.mock import AsyncMock
from librehardwaremonitor_api import (
@@ -266,3 +267,58 @@ async def test_reauth_errors(
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data == {**VALID_CONFIG, **REAUTH_INPUT}
assert len(hass.config_entries.async_entries()) == 1
async def test_deprecated_version_is_rejected_and_flow_recovery(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_lhm_client: AsyncMock,
) -> None:
"""Test that a deprecated LHM version cannot be configured."""
mock_lhm_client.get_data.return_value = replace(
mock_lhm_client.get_data.return_value, is_deprecated_version=True
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=VALID_CONFIG
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"] == {"base": "deprecated_version"}
assert mock_setup_entry.call_count == 0
mock_lhm_client.get_data.return_value = replace(
mock_lhm_client.get_data.return_value, is_deprecated_version=False
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=VALID_CONFIG
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_setup_entry.call_count == 1
@pytest.mark.usefixtures("mock_deprecated_lhm_client")
async def test_reauth_deprecated_version_is_rejected(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that reauth does not complete for a deprecated LHM version."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], REAUTH_INPUT
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert result["errors"] == {"base": "deprecated_version"}
assert mock_config_entry.data == VALID_CONFIG
@@ -1,15 +1,24 @@
"""Tests for the LibreHardwareMonitor init."""
from dataclasses import replace
from datetime import timedelta
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.libre_hardware_monitor.const import DOMAIN
from homeassistant.components.libre_hardware_monitor.const import (
DEFAULT_SCAN_INTERVAL,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import init_integration
from .conftest import VALID_CONFIG
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.usefixtures("mock_lhm_client")
@@ -93,3 +102,39 @@ async def test_migration_to_unique_ids(
legacy_config_entry_v1.entry_id
)
assert updated_config_entry.version == 2
@pytest.mark.usefixtures("mock_deprecated_lhm_client")
async def test_deprecated_version_blocks_setup(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test that a deprecated LHM version prevents setup with an error."""
await init_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert mock_config_entry.error_reason_translation_domain == DOMAIN
assert mock_config_entry.error_reason_translation_key == "deprecated_version"
async def test_downgrade_to_deprecated_version_fails_entry(
hass: HomeAssistant,
mock_lhm_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that downgrading to a deprecated LHM version while running fails the entry."""
await init_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_lhm_client.get_data.return_value = replace(
mock_lhm_client.get_data.return_value, is_deprecated_version=True
)
freezer.tick(timedelta(seconds=DEFAULT_SCAN_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert mock_config_entry.error_reason_translation_domain == DOMAIN
assert mock_config_entry.error_reason_translation_key == "deprecated_version"
@@ -27,11 +27,7 @@ from homeassistant.components.libre_hardware_monitor.const import (
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
from . import init_integration
@@ -346,53 +342,3 @@ async def test_integration_dynamically_adds_new_devices(
assert "sensor.gaming_pc_generic_memory_test_sensor" in [
entry.entity_id for entry in entity_entries
]
async def test_non_deprecated_version_does_not_raise_issue(
hass: HomeAssistant,
mock_lhm_client: AsyncMock,
mock_config_entry: MockConfigEntry,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test non-deprecated LHM version does not raise an issue."""
await init_integration(hass, mock_config_entry)
assert (
DOMAIN,
f"deprecated_api_{mock_config_entry.entry_id}",
) not in issue_registry.issues
async def test_deprecated_version_raises_issue_and_is_removed_after_update(
hass: HomeAssistant,
mock_lhm_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test deprecated LHM version raises issue removed after update."""
mock_lhm_client.get_data.return_value = replace(
mock_lhm_client.get_data.return_value,
is_deprecated_version=True,
)
await init_integration(hass, mock_config_entry)
assert (
DOMAIN,
f"deprecated_api_{mock_config_entry.entry_id}",
) in issue_registry.issues
mock_lhm_client.get_data.return_value = replace(
mock_lhm_client.get_data.return_value,
is_deprecated_version=False,
)
freezer.tick(timedelta(DEFAULT_SCAN_INTERVAL))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (
DOMAIN,
f"deprecated_api_{mock_config_entry.entry_id}",
) not in issue_registry.issues