From cb29a530bc633113cd11840e0b0ee0d6ff8095ac Mon Sep 17 00:00:00 2001 From: Sab44 <64696149+Sab44@users.noreply.github.com> Date: Sat, 3 Oct 2026 08:15:21 +0200 Subject: [PATCH] Add Sensor Device Classes to Libre Hardware Monitor (#182203) --- .../libre_hardware_monitor/__init__.py | 45 +++- .../libre_hardware_monitor/config_flow.py | 1 + .../libre_hardware_monitor/const.py | 3 + .../libre_hardware_monitor/quality_scale.yaml | 2 +- .../libre_hardware_monitor/recorder.py | 29 +++ .../libre_hardware_monitor/sensor.py | 98 +++++-- .../libre_hardware_monitor/conftest.py | 2 + .../fixtures/libre_hardware_monitor.json | 4 +- .../snapshots/test_diagnostics.ambr | 2 +- .../snapshots/test_sensor.ambr | 205 +++++++++++---- .../libre_hardware_monitor/test_init.py | 68 +++++ .../libre_hardware_monitor/test_sensor.py | 241 +++++++++++++++++- 12 files changed, 607 insertions(+), 93 deletions(-) create mode 100644 homeassistant/components/libre_hardware_monitor/recorder.py diff --git a/homeassistant/components/libre_hardware_monitor/__init__.py b/homeassistant/components/libre_hardware_monitor/__init__.py index f3270a6ec5ab..d9e7a518722b 100644 --- a/homeassistant/components/libre_hardware_monitor/__init__.py +++ b/homeassistant/components/libre_hardware_monitor/__init__.py @@ -2,11 +2,11 @@ import logging -from homeassistant.const import Platform +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 +from .const import DOMAIN, THROUGHPUT_UNIQUE_ID_FRAGMENT from .coordinator import ( LibreHardwareMonitorConfigEntry, LibreHardwareMonitorCoordinator, @@ -20,8 +20,10 @@ _LOGGER = logging.getLogger(__name__) async def async_migrate_entry( hass: HomeAssistant, config_entry: LibreHardwareMonitorConfigEntry ) -> bool: - """Migrate non-unique entity and device ids.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) + """Migrate to current config flow version.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) if config_entry.version == 1: # Migrate entity identifiers @@ -61,11 +63,40 @@ async def async_migrate_entry( ) hass.config_entries.async_update_entry( - config_entry, data=config_entry.data, version=2 + config_entry, data=config_entry.data, version=2, minor_version=1 ) - _LOGGER.debug("Migration to version 2 successful") - return True + _LOGGER.debug("Migration to version 2.1 successful") + + if config_entry.version == 2 and config_entry.minor_version == 1: + # Migrate Throughput unit from KB/s to KiB/s + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + throughput_entities = [ + entry + for entry in registry_entries + if THROUGHPUT_UNIQUE_ID_FRAGMENT in entry.unique_id + ] + for reg_entry in throughput_entities: + _LOGGER.debug( + "Migrating entity %s unit from %s to %s", + reg_entry.entity_id, + reg_entry.unit_of_measurement, + UnitOfDataRate.KIBIBYTES_PER_SECOND, + ) + entity_registry.async_update_entity( + reg_entry.entity_id, + unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, + ) + + hass.config_entries.async_update_entry( + config_entry, data=config_entry.data, version=2, minor_version=2 + ) + + _LOGGER.debug("Migration to version 2.2 successful") return True diff --git a/homeassistant/components/libre_hardware_monitor/config_flow.py b/homeassistant/components/libre_hardware_monitor/config_flow.py index 44c8ef289874..5e6d5e56d891 100644 --- a/homeassistant/components/libre_hardware_monitor/config_flow.py +++ b/homeassistant/components/libre_hardware_monitor/config_flow.py @@ -58,6 +58,7 @@ class LibreHardwareMonitorConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for LibreHardwareMonitor.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Init config flow.""" diff --git a/homeassistant/components/libre_hardware_monitor/const.py b/homeassistant/components/libre_hardware_monitor/const.py index 88380a6cf9dd..34efc8a04604 100644 --- a/homeassistant/components/libre_hardware_monitor/const.py +++ b/homeassistant/components/libre_hardware_monitor/const.py @@ -4,3 +4,6 @@ DOMAIN = "libre_hardware_monitor" DEFAULT_HOST = "localhost" DEFAULT_PORT = 8085 DEFAULT_SCAN_INTERVAL = 10 + +LEGACY_THROUGHPUT_UNIT = "KB/s" +THROUGHPUT_UNIQUE_ID_FRAGMENT = "throughput" diff --git a/homeassistant/components/libre_hardware_monitor/quality_scale.yaml b/homeassistant/components/libre_hardware_monitor/quality_scale.yaml index 965590756732..6a15c914fd8c 100644 --- a/homeassistant/components/libre_hardware_monitor/quality_scale.yaml +++ b/homeassistant/components/libre_hardware_monitor/quality_scale.yaml @@ -71,7 +71,7 @@ rules: docs-use-cases: todo dynamic-devices: done entity-category: todo - entity-device-class: todo + entity-device-class: done entity-disabled-by-default: todo entity-translations: todo exception-translations: todo diff --git a/homeassistant/components/libre_hardware_monitor/recorder.py b/homeassistant/components/libre_hardware_monitor/recorder.py new file mode 100644 index 000000000000..18fea7df96f5 --- /dev/null +++ b/homeassistant/components/libre_hardware_monitor/recorder.py @@ -0,0 +1,29 @@ +"""Support for LibreHardwareMonitor Recorder Platform.""" + +from homeassistant.const import UnitOfDataRate +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN, LEGACY_THROUGHPUT_UNIT, THROUGHPUT_UNIQUE_ID_FRAGMENT + + +@callback +def async_custom_equivalent_units( + hass: HomeAssistant, +) -> dict[str, dict[str | None, str]]: + """Return custom equivalent units per entity id. + + Throughput sensors reported the unit as "KB/s" before they got a device class. + """ + entity_registry = er.async_get(hass) + + return { + registry_entry.entity_id: { + LEGACY_THROUGHPUT_UNIT: UnitOfDataRate.KIBIBYTES_PER_SECOND + } + for config_entry in hass.config_entries.async_entries(DOMAIN) + for registry_entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if THROUGHPUT_UNIQUE_ID_FRAGMENT in registry_entry.unique_id + } diff --git a/homeassistant/components/libre_hardware_monitor/sensor.py b/homeassistant/components/libre_hardware_monitor/sensor.py index 4e4111f0a4c1..9b9eee987e27 100644 --- a/homeassistant/components/libre_hardware_monitor/sensor.py +++ b/homeassistant/components/libre_hardware_monitor/sensor.py @@ -6,7 +6,14 @@ from typing import Any, override from librehardwaremonitor_api.model import DeviceId, LibreHardwareMonitorSensorData from librehardwaremonitor_api.sensor_type import SensorType -from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.components.sensor import ( + AMBIGUOUS_UNITS, + UNIT_CONVERTERS, + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import UnitOfDataRate from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -22,6 +29,24 @@ PARALLEL_UPDATES = 0 STATE_MIN_VALUE = "min_value" STATE_MAX_VALUE = "max_value" +DEVICE_CLASSES: dict[SensorType, SensorDeviceClass] = { + SensorType.VOLTAGE: SensorDeviceClass.VOLTAGE, + SensorType.CURRENT: SensorDeviceClass.CURRENT, + SensorType.POWER: SensorDeviceClass.POWER, + SensorType.CLOCK: SensorDeviceClass.FREQUENCY, + SensorType.FREQUENCY: SensorDeviceClass.FREQUENCY, + SensorType.TEMPERATURE: SensorDeviceClass.TEMPERATURE, + SensorType.FLOW: SensorDeviceClass.VOLUME_FLOW_RATE, + SensorType.DATA: SensorDeviceClass.DATA_SIZE, + SensorType.SMALL_DATA: SensorDeviceClass.DATA_SIZE, + SensorType.THROUGHPUT: SensorDeviceClass.DATA_RATE, + SensorType.TIMESPAN: SensorDeviceClass.DURATION, + SensorType.ENERGY: SensorDeviceClass.ENERGY_STORAGE, + SensorType.NOISE: SensorDeviceClass.SOUND_PRESSURE, + SensorType.CONDUCTIVITY: SensorDeviceClass.CONDUCTIVITY, + SensorType.HUMIDITY: SensorDeviceClass.HUMIDITY, +} + async def async_setup_entry( hass: HomeAssistant, @@ -74,6 +99,21 @@ class LibreHardwareMonitorSensor( self._attr_name: str = sensor_data.name + if sensor_data.type is None: + _LOGGER.debug("Missing type for sensor: %s", sensor_data.name) + elif device_class := DEVICE_CLASSES.get(sensor_data.type): + self._attr_device_class = device_class + + if device_class is SensorDeviceClass.DATA_RATE: + self._attr_suggested_unit_of_measurement = ( + UnitOfDataRate.KIBIBYTES_PER_SECOND + ) + # Device class default rounds throughput to whole KiB/s + self._attr_suggested_display_precision = 1 + elif device_class is SensorDeviceClass.VOLTAGE: + # Device class default rounds voltages to whole volts + self._attr_suggested_display_precision = 3 + self._set_state(sensor_data) self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}" @@ -86,33 +126,39 @@ class LibreHardwareMonitorSensor( model=sensor_data.device_type, ) - def _set_state( - self, - sensor_data: LibreHardwareMonitorSensorData, - ) -> None: - value = sensor_data.value - min_value = sensor_data.min - max_value = sensor_data.max - unit = sensor_data.unit + 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 + self._native_min_value = sensor_data.min + self._native_max_value = sensor_data.max - if sensor_data.type == SensorType.THROUGHPUT: - # Temporary fix: convert the B/s value to KB/s to not break existing entries - # This will be migrated properly once SensorDeviceClass is introduced - value = f"{(float(value) / 1024):.1f}" if value is not None else None - min_value = ( - f"{(float(min_value) / 1024):.1f}" if min_value is not None else None - ) - max_value = ( - f"{(float(max_value) / 1024):.1f}" if max_value is not None else None - ) - unit = "KB/s" - - self._attr_native_value: str | None = value - self._attr_extra_state_attributes: dict[str, Any] = { - STATE_MIN_VALUE: min_value, - STATE_MAX_VALUE: max_value, + @property + @override + def extra_state_attributes(self) -> dict[str, Any]: + """Return min and max values in the unit the state is reported in.""" + return { + STATE_MIN_VALUE: self._value_in_state_unit(self._native_min_value), + STATE_MAX_VALUE: self._value_in_state_unit(self._native_max_value), } - self._attr_native_unit_of_measurement = unit + + def _value_in_state_unit(self, native_value: str | None) -> str | float | None: + """Convert a native value to the unit the state is converted to.""" + # Conductivity uses micro-sign U+00B5 spelling in its unit which cannot be converted + # so we swap it with Greek mu U+03BC to get μS/cm as a valid unit + native_unit = AMBIGUOUS_UNITS.get( + self.native_unit_of_measurement, self.native_unit_of_measurement + ) + unit = self.unit_of_measurement + if ( + native_value is None + or native_unit == unit + or (converter := UNIT_CONVERTERS.get(self.device_class)) is None + or native_unit not in converter.VALID_UNITS + or unit not in converter.VALID_UNITS + ): + return native_value + + return converter.convert(float(native_value), native_unit, unit) @callback @override diff --git a/tests/components/libre_hardware_monitor/conftest.py b/tests/components/libre_hardware_monitor/conftest.py index e168123f5a20..ee3c498348de 100644 --- a/tests/components/libre_hardware_monitor/conftest.py +++ b/tests/components/libre_hardware_monitor/conftest.py @@ -49,6 +49,7 @@ def mock_config_entry() -> MockConfigEntry: data=VALID_CONFIG, entry_id="test_entry_id", version=2, + minor_version=2, ) @@ -61,6 +62,7 @@ def mock_auth_config_entry() -> MockConfigEntry: data=VALID_CONFIG_WITH_AUTH, entry_id="test_entry_id", version=2, + minor_version=2, ) diff --git a/tests/components/libre_hardware_monitor/fixtures/libre_hardware_monitor.json b/tests/components/libre_hardware_monitor/fixtures/libre_hardware_monitor.json index b50c8546b070..a8130e4f1b5b 100644 --- a/tests/components/libre_hardware_monitor/fixtures/libre_hardware_monitor.json +++ b/tests/components/libre_hardware_monitor/fixtures/libre_hardware_monitor.json @@ -524,12 +524,12 @@ { "id": 43, "Text": "GPU PCIe Tx", - "Min": "0,0 KB/s", + "Min": "1,0 KB/s", "Value": "278,6 MB/s", "Max": "357,6 MB/s", "SensorId": "/gpu-nvidia/0/throughput/1", "Type": "Throughput", - "RawMin": "0,0 B/s", + "RawMin": "1000,0 B/s", "RawValue": "292149200,0 B/s", "RawMax": "374999000,0 B/s", "ImageURL": "images/transparent.png", diff --git a/tests/components/libre_hardware_monitor/snapshots/test_diagnostics.ambr b/tests/components/libre_hardware_monitor/snapshots/test_diagnostics.ambr index f5095083131f..262ef32e766e 100644 --- a/tests/components/libre_hardware_monitor/snapshots/test_diagnostics.ambr +++ b/tests/components/libre_hardware_monitor/snapshots/test_diagnostics.ambr @@ -213,7 +213,7 @@ 'device_name': 'NVIDIA GeForce RTX 4080 SUPER', 'device_type': 'NVIDIA', 'max': '374999000.0', - 'min': '0.0', + 'min': '1000.0', 'name': 'GPU PCIe Tx Throughput', 'sensor_id': 'gpu-nvidia-0-throughput-1', 'type': 'Throughput', diff --git a/tests/components/libre_hardware_monitor/snapshots/test_sensor.ambr b/tests/components/libre_hardware_monitor/snapshots/test_sensor.ambr index 4bbe641e307b..9c7d428eb32c 100644 --- a/tests/components/libre_hardware_monitor/snapshots/test_sensor.ambr +++ b/tests/components/libre_hardware_monitor/snapshots/test_sensor.ambr @@ -25,8 +25,11 @@ 'name': None, 'object_id_base': 'Core (Tctl/Tdie) Temperature', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Core (Tctl/Tdie) Temperature', 'platform': 'libre_hardware_monitor', @@ -35,17 +38,18 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test_entry_id_amdcpu-0-temperature-2', - 'unit_of_measurement': '°C', + 'unit_of_measurement': , }) # --- # name: test_sensors_are_created[sensor.gaming_pc_amd_ryzen_7_7800x3d_core_tctl_tdie_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] AMD Ryzen 7 7800X3D Core (Tctl/Tdie) Temperature', 'max_value': '69.1', 'min_value': '39.4', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_amd_ryzen_7_7800x3d_core_tctl_tdie_temperature', @@ -137,8 +141,11 @@ 'name': None, 'object_id_base': 'Package Power', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Package Power', 'platform': 'libre_hardware_monitor', @@ -153,6 +160,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_amd_ryzen_7_7800x3d_package_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'power', : '[GAMING-PC] AMD Ryzen 7 7800X3D Package Power', 'max_value': '70.1', 'min_value': '25.1', @@ -193,8 +201,11 @@ 'name': None, 'object_id_base': 'Package Temperature', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Package Temperature', 'platform': 'libre_hardware_monitor', @@ -203,17 +214,18 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test_entry_id_amdcpu-0-temperature-3', - 'unit_of_measurement': '°C', + 'unit_of_measurement': , }) # --- # name: test_sensors_are_created[sensor.gaming_pc_amd_ryzen_7_7800x3d_package_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] AMD Ryzen 7 7800X3D Package Temperature', 'max_value': '74.0', 'min_value': '38.4', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_amd_ryzen_7_7800x3d_package_temperature', @@ -249,8 +261,11 @@ 'name': None, 'object_id_base': 'VDDCR SoC Voltage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'VDDCR SoC Voltage', 'platform': 'libre_hardware_monitor', @@ -265,6 +280,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_amd_ryzen_7_7800x3d_vddcr_soc_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] AMD Ryzen 7 7800X3D VDDCR SoC Voltage', 'max_value': '1.306', 'min_value': '1.305', @@ -305,8 +321,11 @@ 'name': None, 'object_id_base': 'VDDCR Voltage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'VDDCR Voltage', 'platform': 'libre_hardware_monitor', @@ -321,6 +340,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_amd_ryzen_7_7800x3d_vddcr_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] AMD Ryzen 7 7800X3D VDDCR Voltage', 'max_value': '1.173', 'min_value': '0.452', @@ -361,8 +381,11 @@ 'name': None, 'object_id_base': '+12V Voltage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': '+12V Voltage', 'platform': 'libre_hardware_monitor', @@ -377,6 +400,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_12v_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) +12V Voltage', 'max_value': '12.096', 'min_value': '12.048', @@ -417,8 +441,11 @@ 'name': None, 'object_id_base': '+5V Voltage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': '+5V Voltage', 'platform': 'libre_hardware_monitor', @@ -433,6 +460,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_5v_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) +5V Voltage', 'max_value': '5.050', 'min_value': '5.020', @@ -529,8 +557,11 @@ 'name': None, 'object_id_base': 'CPU Temperature', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'CPU Temperature', 'platform': 'libre_hardware_monitor', @@ -539,17 +570,18 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test_entry_id_lpc-nct6687d-0-temperature-0', - 'unit_of_measurement': '°C', + 'unit_of_measurement': , }) # --- # name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Temperature', 'max_value': '68.0', 'min_value': '39.0', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature', @@ -696,8 +728,11 @@ 'name': None, 'object_id_base': 'System Temperature', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'System Temperature', 'platform': 'libre_hardware_monitor', @@ -706,17 +741,18 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test_entry_id_lpc-nct6687d-0-temperature-1', - 'unit_of_measurement': '°C', + 'unit_of_measurement': , }) # --- # name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Temperature', 'max_value': '46.5', 'min_value': '32.5', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature', @@ -752,8 +788,11 @@ 'name': None, 'object_id_base': 'Vcore Voltage', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Vcore Voltage', 'platform': 'libre_hardware_monitor', @@ -768,6 +807,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_vcore_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Vcore Voltage', 'max_value': '1.318', 'min_value': '1.310', @@ -808,8 +848,11 @@ 'name': None, 'object_id_base': 'GPU Core Clock', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'GPU Core Clock', 'platform': 'libre_hardware_monitor', @@ -824,6 +867,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_core_clock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'frequency', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Core Clock', 'max_value': '2805.0', 'min_value': '210.0', @@ -920,8 +964,11 @@ 'name': None, 'object_id_base': 'GPU Core Temperature', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'GPU Core Temperature', 'platform': 'libre_hardware_monitor', @@ -930,17 +977,18 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test_entry_id_gpu-nvidia-0-temperature-0', - 'unit_of_measurement': '°C', + 'unit_of_measurement': , }) # --- # name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_core_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Core Temperature', 'max_value': '37.0', 'min_value': '25.0', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_core_temperature', @@ -1088,8 +1136,11 @@ 'name': None, 'object_id_base': 'GPU Hot Spot Temperature', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'GPU Hot Spot Temperature', 'platform': 'libre_hardware_monitor', @@ -1098,17 +1149,18 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test_entry_id_gpu-nvidia-0-temperature-2', - 'unit_of_measurement': '°C', + 'unit_of_measurement': , }) # --- # name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Hot Spot Temperature', 'max_value': '43.3', 'min_value': '32.5', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature', @@ -1144,8 +1196,11 @@ 'name': None, 'object_id_base': 'GPU Memory Clock', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'GPU Memory Clock', 'platform': 'libre_hardware_monitor', @@ -1160,6 +1215,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_memory_clock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'frequency', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Memory Clock', 'max_value': '11502.0', 'min_value': '405.0', @@ -1256,8 +1312,11 @@ 'name': None, 'object_id_base': 'GPU Package Power', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'GPU Package Power', 'platform': 'libre_hardware_monitor', @@ -1272,6 +1331,7 @@ # name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_package_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'power', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Package Power', 'max_value': '66.6', 'min_value': '4.1', @@ -1312,8 +1372,14 @@ 'name': None, 'object_id_base': 'GPU PCIe Tx Throughput', 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'GPU PCIe Tx Throughput', 'platform': 'libre_hardware_monitor', @@ -1322,24 +1388,25 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': 'test_entry_id_gpu-nvidia-0-throughput-1', - 'unit_of_measurement': 'KB/s', + 'unit_of_measurement': , }) # --- # name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'data_rate', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU PCIe Tx Throughput', - 'max_value': '366210.0', - 'min_value': '0.0', + 'max_value': 366209.9609375, + 'min_value': 0.9765625, : , - : 'KB/s', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '285302.0', + 'state': '285301.953125', }) # --- # name: test_sensors_are_created[sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_video_engine_load-entry] @@ -1402,6 +1469,7 @@ list([ StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) +12V Voltage', 'max_value': '12.096', 'min_value': '12.048', @@ -1417,6 +1485,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) +5V Voltage', 'max_value': '5.050', 'min_value': '5.020', @@ -1432,6 +1501,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Vcore Voltage', 'max_value': '1.318', 'min_value': '1.310', @@ -1447,11 +1517,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Temperature', 'max_value': '68.0', 'min_value': '39.0', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature', @@ -1462,11 +1533,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Temperature', 'max_value': '46.5', 'min_value': '32.5', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature', @@ -1521,6 +1593,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] AMD Ryzen 7 7800X3D VDDCR Voltage', 'max_value': '1.173', 'min_value': '0.452', @@ -1536,6 +1609,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] AMD Ryzen 7 7800X3D VDDCR SoC Voltage', 'max_value': '1.306', 'min_value': '1.305', @@ -1551,6 +1625,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'power', : '[GAMING-PC] AMD Ryzen 7 7800X3D Package Power', 'max_value': '70.1', 'min_value': '25.1', @@ -1566,11 +1641,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] AMD Ryzen 7 7800X3D Core (Tctl/Tdie) Temperature', 'max_value': '69.1', 'min_value': '39.4', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_amd_ryzen_7_7800x3d_core_tctl_tdie_temperature', @@ -1581,11 +1657,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] AMD Ryzen 7 7800X3D Package Temperature', 'max_value': '74.0', 'min_value': '38.4', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_amd_ryzen_7_7800x3d_package_temperature', @@ -1611,6 +1688,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'power', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Package Power', 'max_value': '66.6', 'min_value': '4.1', @@ -1626,6 +1704,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'frequency', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Core Clock', 'max_value': '2805.0', 'min_value': '210.0', @@ -1641,6 +1720,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'frequency', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Memory Clock', 'max_value': '11502.0', 'min_value': '405.0', @@ -1656,11 +1736,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Core Temperature', 'max_value': '37.0', 'min_value': '25.0', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_core_temperature', @@ -1671,11 +1752,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Hot Spot Temperature', 'max_value': '43.3', 'min_value': '32.5', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature', @@ -1761,18 +1843,19 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'data_rate', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU PCIe Tx Throughput', - 'max_value': '366210.0', - 'min_value': '0.0', + 'max_value': 366209.9609375, + 'min_value': 0.9765625, : , - : 'KB/s', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '285302.0', + 'state': '285301.953125', }), ]) # --- @@ -1780,6 +1863,7 @@ list([ StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) +12V Voltage', 'max_value': '12.096', 'min_value': '12.048', @@ -1795,6 +1879,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) +5V Voltage', 'max_value': '5.050', 'min_value': '5.020', @@ -1810,6 +1895,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) Vcore Voltage', 'max_value': '1.318', 'min_value': '1.310', @@ -1825,11 +1911,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) CPU Temperature', 'max_value': '68.0', 'min_value': '39.0', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_cpu_temperature', @@ -1840,11 +1927,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] MSI MAG B650M MORTAR WIFI (MS-7D76) System Temperature', 'max_value': '46.5', 'min_value': '32.5', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_msi_mag_b650m_mortar_wifi_ms_7d76_system_temperature', @@ -1899,6 +1987,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] AMD Ryzen 7 7800X3D VDDCR Voltage', 'max_value': '1.173', 'min_value': '0.452', @@ -1914,6 +2003,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'voltage', : '[GAMING-PC] AMD Ryzen 7 7800X3D VDDCR SoC Voltage', 'max_value': '1.306', 'min_value': '1.305', @@ -1929,6 +2019,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'power', : '[GAMING-PC] AMD Ryzen 7 7800X3D Package Power', 'max_value': '70.1', 'min_value': '25.1', @@ -1944,11 +2035,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] AMD Ryzen 7 7800X3D Core (Tctl/Tdie) Temperature', 'max_value': '69.1', 'min_value': '39.4', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_amd_ryzen_7_7800x3d_core_tctl_tdie_temperature', @@ -1959,11 +2051,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] AMD Ryzen 7 7800X3D Package Temperature', 'max_value': '74.0', 'min_value': '38.4', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_amd_ryzen_7_7800x3d_package_temperature', @@ -1989,6 +2082,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'power', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Package Power', 'max_value': '66.6', 'min_value': '4.1', @@ -2004,6 +2098,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'frequency', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Core Clock', 'max_value': '2805.0', 'min_value': '210.0', @@ -2019,6 +2114,7 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'frequency', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Memory Clock', 'max_value': '11502.0', 'min_value': '405.0', @@ -2034,11 +2130,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Core Temperature', 'max_value': '37.0', 'min_value': '25.0', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_core_temperature', @@ -2049,11 +2146,12 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'temperature', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU Hot Spot Temperature', 'max_value': '43.3', 'min_value': '32.5', : , - : '°C', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_hot_spot_temperature', @@ -2139,18 +2237,19 @@ }), StateSnapshot({ 'attributes': ReadOnlyDict({ + : 'data_rate', : '[GAMING-PC] NVIDIA GeForce RTX 4080 SUPER GPU PCIe Tx Throughput', - 'max_value': '366210.0', - 'min_value': '0.0', + 'max_value': 366209.9609375, + 'min_value': 0.9765625, : , - : 'KB/s', + : , }), 'context': , 'entity_id': 'sensor.gaming_pc_nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '285302.0', + 'state': '285301.953125', }), ]) # --- diff --git a/tests/components/libre_hardware_monitor/test_init.py b/tests/components/libre_hardware_monitor/test_init.py index 385bb109acdc..e896cfae2282 100644 --- a/tests/components/libre_hardware_monitor/test_init.py +++ b/tests/components/libre_hardware_monitor/test_init.py @@ -10,8 +10,13 @@ import pytest from homeassistant.components.libre_hardware_monitor.const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, + LEGACY_THROUGHPUT_UNIT, +) +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.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -102,6 +107,69 @@ async def test_migration_to_unique_ids( legacy_config_entry_v1.entry_id ) assert updated_config_entry.version == 2 + assert updated_config_entry.minor_version == 2 + + +@pytest.mark.parametrize( + ("version", "minor_version", "unique_id_prefix"), + [ + pytest.param(1, 1, "lhm-", id="from_v1"), + pytest.param(2, 1, "test_entry_id_", id="from_v2_minor_1"), + ], +) +@pytest.mark.usefixtures("mock_lhm_client", "recorder_mock") +async def test_migration_to_sensor_device_classes( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + version: int, + minor_version: int, + unique_id_prefix: str, +) -> None: + """Test that throughput sensor units are updated from every legacy version.""" + legacy_config_entry = MockConfigEntry( + domain=DOMAIN, + title="192.168.0.20:8085", + data=VALID_CONFIG, + entry_id="test_entry_id", + version=version, + minor_version=minor_version, + ) + legacy_config_entry.add_to_hass(hass) + + # Set up throughput sensor with old unit + object_id = "nvidia_geforce_rtx_4080_gpu_pcie_tx_throughput" + entity_registry.async_get_or_create( + "sensor", + DOMAIN, + f"{unique_id_prefix}gpu-nvidia-0-throughput-1", + suggested_object_id=object_id, + config_entry=legacy_config_entry, + unit_of_measurement=LEGACY_THROUGHPUT_UNIT, + ) + + await init_integration(hass, legacy_config_entry) + + entity_entry = entity_registry.async_get(f"sensor.{object_id}") + assert entity_entry.unit_of_measurement == UnitOfDataRate.KIBIBYTES_PER_SECOND + + # the entity keeps reporting the migrated unit once it is set up + state = hass.states.get(f"sensor.{object_id}") + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ( + UnitOfDataRate.KIBIBYTES_PER_SECOND + ) + + custom_equivalent_units = async_custom_equivalent_units(hass) + assert custom_equivalent_units == { + f"sensor.{object_id}": { + LEGACY_THROUGHPUT_UNIT: UnitOfDataRate.KIBIBYTES_PER_SECOND + } + } + + updated_config_entry = hass.config_entries.async_get_entry( + legacy_config_entry.entry_id + ) + assert updated_config_entry.version == 2 + assert updated_config_entry.minor_version == 2 @pytest.mark.usefixtures("mock_deprecated_lhm_client") diff --git a/tests/components/libre_hardware_monitor/test_sensor.py b/tests/components/libre_hardware_monitor/test_sensor.py index 11ba0abe669c..503d0a930ca5 100644 --- a/tests/components/libre_hardware_monitor/test_sensor.py +++ b/tests/components/libre_hardware_monitor/test_sensor.py @@ -24,8 +24,31 @@ from homeassistant.components.libre_hardware_monitor.const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, ) +from homeassistant.components.libre_hardware_monitor.sensor import ( + STATE_MAX_VALUE, + STATE_MIN_VALUE, +) +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfConductivity, + UnitOfDataRate, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfInformation, + UnitOfPower, + UnitOfSoundPressure, + UnitOfTemperature, + UnitOfTime, + UnitOfVolumeFlowRate, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry @@ -48,6 +71,218 @@ async def test_sensors_are_created( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.parametrize( + ("sensor_type", "lhm_unit", "expected_device_class", "expected_unit"), + [ + pytest.param( + SensorType.VOLTAGE, + "V", + SensorDeviceClass.VOLTAGE, + UnitOfElectricPotential.VOLT, + id="voltage", + ), + pytest.param( + SensorType.CURRENT, + "A", + SensorDeviceClass.CURRENT, + UnitOfElectricCurrent.AMPERE, + id="current", + ), + pytest.param( + SensorType.POWER, + "W", + SensorDeviceClass.POWER, + UnitOfPower.WATT, + id="power", + ), + pytest.param( + SensorType.CLOCK, + "MHz", + SensorDeviceClass.FREQUENCY, + UnitOfFrequency.MEGAHERTZ, + id="clock", + ), + pytest.param( + SensorType.FREQUENCY, + "Hz", + SensorDeviceClass.FREQUENCY, + UnitOfFrequency.HERTZ, + id="frequency", + ), + pytest.param( + SensorType.TEMPERATURE, + "°C", + SensorDeviceClass.TEMPERATURE, + UnitOfTemperature.CELSIUS, + id="temperature", + ), + pytest.param( + SensorType.FLOW, + "L/h", + SensorDeviceClass.VOLUME_FLOW_RATE, + UnitOfVolumeFlowRate.LITERS_PER_HOUR, + id="flow", + ), + pytest.param( + SensorType.DATA, + "GB", + SensorDeviceClass.DATA_SIZE, + UnitOfInformation.GIGABYTES, + id="data", + ), + pytest.param( + SensorType.SMALL_DATA, + "MB", + SensorDeviceClass.DATA_SIZE, + UnitOfInformation.MEGABYTES, + id="small_data", + ), + pytest.param( + SensorType.THROUGHPUT, + "B/s", + SensorDeviceClass.DATA_RATE, + UnitOfDataRate.KIBIBYTES_PER_SECOND, + id="throughput", + ), + pytest.param( + SensorType.TIMESPAN, + "s", + SensorDeviceClass.DURATION, + UnitOfTime.SECONDS, + id="timespan", + ), + pytest.param( + SensorType.ENERGY, + "mWh", + SensorDeviceClass.ENERGY_STORAGE, + UnitOfEnergy.MILLIWATT_HOUR, + id="energy", + ), + pytest.param( + SensorType.NOISE, + "dBA", + SensorDeviceClass.SOUND_PRESSURE, + UnitOfSoundPressure.WEIGHTED_DECIBEL_A, + id="noise", + ), + pytest.param( + SensorType.CONDUCTIVITY, + # LHM sends the micro sign (U+00B5), HA normalizes it to greek mu (U+03BC) + "\u00b5S/cm", + SensorDeviceClass.CONDUCTIVITY, + UnitOfConductivity.MICROSIEMENS_PER_CM, + id="conductivity", + ), + pytest.param( + SensorType.HUMIDITY, + "%", + SensorDeviceClass.HUMIDITY, + PERCENTAGE, + id="humidity", + ), + pytest.param(SensorType.FACTOR, None, None, None, id="factor_unmapped"), + pytest.param(None, None, None, None, id="unknown_type"), + ], +) +async def test_sensor_device_class_mapping( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, + sensor_type: SensorType | None, + lhm_unit: str | None, + expected_device_class: SensorDeviceClass | None, + expected_unit: str | None, +) -> None: + """Test every LHM sensor type gets the expected device class and unit.""" + sensor_id = "gpu-nvidia-0-test-0" + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, + sensor_data=MappingProxyType( + { + sensor_id: LibreHardwareMonitorSensorData( + name="Test", + value="42.0", + type=sensor_type, + min="40.0", + max="44.0", + unit=lhm_unit, + device_id="gpu-nvidia-0", + device_name="NVIDIA GeForce RTX 4080 SUPER", + device_type="NVIDIA", + sensor_id=sensor_id, + ) + } + ), + ) + + await init_integration(hass, mock_config_entry) + + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{mock_config_entry.entry_id}_{sensor_id}" + ) + assert entity_id + + state = hass.states.get(entity_id) + + assert state + assert state.attributes.get(ATTR_DEVICE_CLASS) == expected_device_class + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == expected_unit + + +async def test_min_max_follow_selected_conductivity_unit( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lhm_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test min and max are converted from the micro sign spelling LHM reports.""" + sensor_id = "gpu-nvidia-0-conductivity-0" + mock_lhm_client.get_data.return_value = replace( + mock_lhm_client.get_data.return_value, + sensor_data=MappingProxyType( + { + sensor_id: LibreHardwareMonitorSensorData( + name="Coolant", + value="42.0", + type=SensorType.CONDUCTIVITY, + min="40.0", + max="44.0", + unit="\u00b5S/cm", + device_id="gpu-nvidia-0", + device_name="NVIDIA GeForce RTX 4080 SUPER", + device_type="NVIDIA", + sensor_id=sensor_id, + ) + } + ), + ) + await init_integration(hass, mock_config_entry) + + entity_id = entity_registry.async_get_entity_id( + "sensor", DOMAIN, f"{mock_config_entry.entry_id}_{sensor_id}" + ) + assert entity_id + + entity_registry.async_update_entity_options( + entity_id, + "sensor", + {"unit_of_measurement": UnitOfConductivity.MILLISIEMENS_PER_CM}, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + + assert state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ( + UnitOfConductivity.MILLISIEMENS_PER_CM + ) + assert float(state.state) == pytest.approx(0.042) + assert state.attributes[STATE_MIN_VALUE] == pytest.approx(0.04) + assert state.attributes[STATE_MAX_VALUE] == pytest.approx(0.044) + + @pytest.mark.parametrize( "error", [LibreHardwareMonitorConnectionError, LibreHardwareMonitorNoDevicesError] ) @@ -141,8 +376,8 @@ async def test_sensor_invalid_auth_during_startup( ( "gaming_pc_nvidia_geforce_rtx_4080_super_gpu_pcie_tx_throughput", "gpu-nvidia-0-throughput-1", - "792150000.0", - "773584.0", + "811161600.0", + "792150.0", ), ], )