From e130747f6c1939b9ab63549851c84e87478df7cb Mon Sep 17 00:00:00 2001 From: Sab44 <64696149+Sab44@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:50:37 +0200 Subject: [PATCH] Fix data sensors unit to use MiB or GiB as values are binary based (#184243) --- .../libre_hardware_monitor/__init__.py | 24 +++- .../libre_hardware_monitor/const.py | 8 ++ .../libre_hardware_monitor/sensor.py | 14 ++- .../libre_hardware_monitor/test_init.py | 114 +++++++++++++++++- .../libre_hardware_monitor/test_sensor.py | 13 +- 5 files changed, 166 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/libre_hardware_monitor/__init__.py b/homeassistant/components/libre_hardware_monitor/__init__.py index d9e7a518722b..a6c7161d8e1e 100644 --- a/homeassistant/components/libre_hardware_monitor/__init__.py +++ b/homeassistant/components/libre_hardware_monitor/__init__.py @@ -6,7 +6,11 @@ from homeassistant.const import Platform, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import DOMAIN, THROUGHPUT_UNIQUE_ID_FRAGMENT +from .const import ( + DOMAIN, + LEGACY_DATA_SIZE_UNIT_EQUIVALENTS, + THROUGHPUT_UNIQUE_ID_FRAGMENT, +) from .coordinator import ( LibreHardwareMonitorConfigEntry, LibreHardwareMonitorCoordinator, @@ -92,6 +96,24 @@ async def async_migrate_entry( unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, ) + # Migrate binary Data units LHM labels MB and GB to MiB and GiB + for reg_entry in registry_entries: + if not ( + data_size_unit := LEGACY_DATA_SIZE_UNIT_EQUIVALENTS.get( + reg_entry.unit_of_measurement + ) + ): + continue + _LOGGER.debug( + "Migrating entity %s unit from %s to %s", + reg_entry.entity_id, + reg_entry.unit_of_measurement, + data_size_unit, + ) + entity_registry.async_update_entity( + reg_entry.entity_id, unit_of_measurement=data_size_unit + ) + hass.config_entries.async_update_entry( config_entry, data=config_entry.data, version=2, minor_version=2 ) diff --git a/homeassistant/components/libre_hardware_monitor/const.py b/homeassistant/components/libre_hardware_monitor/const.py index 34efc8a04604..3af6c5f5fff6 100644 --- a/homeassistant/components/libre_hardware_monitor/const.py +++ b/homeassistant/components/libre_hardware_monitor/const.py @@ -1,5 +1,7 @@ """Constants for the LibreHardwareMonitor integration.""" +from homeassistant.const import UnitOfInformation + DOMAIN = "libre_hardware_monitor" DEFAULT_HOST = "localhost" DEFAULT_PORT = 8085 @@ -7,3 +9,9 @@ DEFAULT_SCAN_INTERVAL = 10 LEGACY_THROUGHPUT_UNIT = "KB/s" THROUGHPUT_UNIQUE_ID_FRAGMENT = "throughput" + +# LHM labels its data sizes MB and GB but calculates them with binary multiples +LEGACY_DATA_SIZE_UNIT_EQUIVALENTS: dict[str | None, str] = { + "MB": UnitOfInformation.MEBIBYTES, + "GB": UnitOfInformation.GIBIBYTES, +} diff --git a/homeassistant/components/libre_hardware_monitor/sensor.py b/homeassistant/components/libre_hardware_monitor/sensor.py index 9b9eee987e27..a2102a5c4f1e 100644 --- a/homeassistant/components/libre_hardware_monitor/sensor.py +++ b/homeassistant/components/libre_hardware_monitor/sensor.py @@ -13,14 +13,14 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.const import UnitOfDataRate +from homeassistant.const import UnitOfDataRate, UnitOfInformation from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LibreHardwareMonitorConfigEntry, LibreHardwareMonitorCoordinator -from .const import DOMAIN +from .const import DOMAIN, LEGACY_DATA_SIZE_UNIT_EQUIVALENTS _LOGGER = logging.getLogger(__name__) @@ -113,6 +113,9 @@ class LibreHardwareMonitorSensor( elif device_class is SensorDeviceClass.VOLTAGE: # Device class default rounds voltages to whole volts self._attr_suggested_display_precision = 3 + elif device_class is SensorDeviceClass.DATA_SIZE: + # LHM versions >= 0.9.7 report data sizes in raw bytes + self._attr_suggested_unit_of_measurement = UnitOfInformation.GIBIBYTES self._set_state(sensor_data) self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}" @@ -128,7 +131,12 @@ class LibreHardwareMonitorSensor( def _set_state(self, sensor_data: LibreHardwareMonitorSensorData) -> None: self._attr_native_value: str | None = sensor_data.value - self._attr_native_unit_of_measurement = sensor_data.unit + # This conversion is only needed for LHM versions < 0.9.7 + self._attr_native_unit_of_measurement = ( + LEGACY_DATA_SIZE_UNIT_EQUIVALENTS.get(sensor_data.unit, sensor_data.unit) + if sensor_data.type in (SensorType.DATA, SensorType.SMALL_DATA) + else sensor_data.unit + ) self._native_min_value = sensor_data.min self._native_max_value = sensor_data.max diff --git a/tests/components/libre_hardware_monitor/test_init.py b/tests/components/libre_hardware_monitor/test_init.py index e896cfae2282..3bd4516096a3 100644 --- a/tests/components/libre_hardware_monitor/test_init.py +++ b/tests/components/libre_hardware_monitor/test_init.py @@ -2,9 +2,12 @@ from dataclasses import replace from datetime import timedelta +from types import MappingProxyType from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory +from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData +from librehardwaremonitor_api.sensor_type import SensorType import pytest from homeassistant.components.libre_hardware_monitor.const import ( @@ -16,7 +19,11 @@ from homeassistant.components.libre_hardware_monitor.recorder import ( async_custom_equivalent_units, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfDataRate +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + UnitOfDataRate, + UnitOfInformation, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -172,6 +179,111 @@ async def test_migration_to_sensor_device_classes( assert updated_config_entry.minor_version == 2 +@pytest.mark.parametrize( + ( + "sensor_type", + "lhm_value", + "lhm_unit", + "legacy_unit", + "expected_value", + "expected_unit", + ), + [ + pytest.param( + SensorType.SMALL_DATA, + "733.0", + "MB", + "MB", + 733.0, + UnitOfInformation.MEBIBYTES, + id="small_data", + ), + pytest.param( + SensorType.DATA, + "16.0", + "GB", + "GB", + 16.0, + UnitOfInformation.GIBIBYTES, + id="data", + ), + pytest.param( + SensorType.DATA, + "17179869184", + "B", + "GB", + 16.0, + UnitOfInformation.GIBIBYTES, + id="data_reported_in_bytes", + ), + ], +) +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_to_binary_data_size_units( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lhm_client: AsyncMock, + sensor_type: SensorType, + lhm_value: str, + lhm_unit: str, + legacy_unit: str, + expected_value: float, + expected_unit: str, +) -> None: + """Test that data sizes LHM labels MB and GB move to MiB and GiB.""" + legacy_config_entry = MockConfigEntry( + domain=DOMAIN, + title="192.168.0.20:8085", + data=VALID_CONFIG, + entry_id="test_entry_id", + version=2, + minor_version=1, + ) + legacy_config_entry.add_to_hass(hass) + + sensor_id = "gpu-nvidia-0-data-0" + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, + sensor_data=MappingProxyType( + { + sensor_id: LibreHardwareMonitorSensorData( + name="GPU Memory Total", + value=lhm_value, + type=sensor_type, + min=lhm_value, + max=lhm_value, + unit=lhm_unit, + device_id="gpu-nvidia-0", + device_name="NVIDIA GeForce RTX 4080 SUPER", + device_type="NVIDIA", + sensor_id=sensor_id, + ) + } + ), + ) + + # Set up data size sensor with the unit it had before device classes + object_id = "nvidia_geforce_rtx_4080_gpu_memory_total" + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + f"{legacy_config_entry.entry_id}_{sensor_id}", + suggested_object_id=object_id, + config_entry=legacy_config_entry, + unit_of_measurement=legacy_unit, + ) + + await init_integration(hass, legacy_config_entry) + + entity_entry = entity_registry.async_get(f"sensor.{object_id}") + assert entity_entry.unit_of_measurement == expected_unit + + # LHM always calculated binary sizes, so the value is relabelled, not converted + state = hass.states.get(f"sensor.{object_id}") + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == expected_unit + assert float(state.state) == expected_value + + @pytest.mark.usefixtures("mock_deprecated_lhm_client") async def test_deprecated_version_blocks_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/libre_hardware_monitor/test_sensor.py b/tests/components/libre_hardware_monitor/test_sensor.py index 503d0a930ca5..315c6e349dd3 100644 --- a/tests/components/libre_hardware_monitor/test_sensor.py +++ b/tests/components/libre_hardware_monitor/test_sensor.py @@ -125,16 +125,25 @@ async def test_sensors_are_created( ), pytest.param( SensorType.DATA, + # LHM labels binary gigabytes as GB "GB", SensorDeviceClass.DATA_SIZE, - UnitOfInformation.GIGABYTES, + UnitOfInformation.GIBIBYTES, id="data", ), + pytest.param( + SensorType.DATA, + "B", + SensorDeviceClass.DATA_SIZE, + UnitOfInformation.GIBIBYTES, + id="data_bytes", + ), pytest.param( SensorType.SMALL_DATA, + # LHM labels binary megabytes as MB "MB", SensorDeviceClass.DATA_SIZE, - UnitOfInformation.MEGABYTES, + UnitOfInformation.GIBIBYTES, id="small_data", ), pytest.param(