From ba62d95715d8bb00a74b9488723e22b8e83eef8f Mon Sep 17 00:00:00 2001 From: elgris <1905821+elgris@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:58:09 +0100 Subject: [PATCH 1/4] Control time display format on SwitchBot Meter Pro CO2 (#163008) Co-authored-by: Joostlek --- .../components/switchbot/__init__.py | 3 +- homeassistant/components/switchbot/const.py | 2 +- homeassistant/components/switchbot/select.py | 75 +++++++++++ .../components/switchbot/strings.json | 9 ++ tests/components/switchbot/__init__.py | 4 +- tests/components/switchbot/test_select.py | 125 ++++++++++++++++++ tests/components/switchbot/test_sensor.py | 2 +- 7 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/switchbot/select.py create mode 100644 tests/components/switchbot/test_select.py diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index d77e9e2df4e2f8..e24751c9a40102 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -53,7 +53,7 @@ Platform.SENSOR, ], SupportedModels.HYGROMETER.value: [Platform.SENSOR], - SupportedModels.HYGROMETER_CO2.value: [Platform.SENSOR], + SupportedModels.HYGROMETER_CO2.value: [Platform.SENSOR, Platform.SELECT], SupportedModels.CONTACT.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.MOTION.value: [Platform.BINARY_SENSOR, Platform.SENSOR], SupportedModels.PRESENCE_SENSOR.value: [Platform.BINARY_SENSOR, Platform.SENSOR], @@ -164,6 +164,7 @@ SupportedModels.ART_FRAME.value: switchbot.SwitchbotArtFrame, SupportedModels.KEYPAD_VISION.value: switchbot.SwitchbotKeypadVision, SupportedModels.KEYPAD_VISION_PRO.value: switchbot.SwitchbotKeypadVision, + SupportedModels.HYGROMETER_CO2.value: switchbot.SwitchbotMeterProCO2, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 8617f82d6cff4e..a94c52dba816bf 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -106,13 +106,13 @@ class SupportedModels(StrEnum): SwitchbotModel.ART_FRAME: SupportedModels.ART_FRAME, SwitchbotModel.KEYPAD_VISION: SupportedModels.KEYPAD_VISION, SwitchbotModel.KEYPAD_VISION_PRO: SupportedModels.KEYPAD_VISION_PRO, + SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.METER: SupportedModels.HYGROMETER, SwitchbotModel.IO_METER: SupportedModels.HYGROMETER, SwitchbotModel.METER_PRO: SupportedModels.HYGROMETER, - SwitchbotModel.METER_PRO_C: SupportedModels.HYGROMETER_CO2, SwitchbotModel.CONTACT_SENSOR: SupportedModels.CONTACT, SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION, SwitchbotModel.PRESENCE_SENSOR: SupportedModels.PRESENCE_SENSOR, diff --git a/homeassistant/components/switchbot/select.py b/homeassistant/components/switchbot/select.py new file mode 100644 index 00000000000000..5322b22f2c34f3 --- /dev/null +++ b/homeassistant/components/switchbot/select.py @@ -0,0 +1,75 @@ +"""Select platform for SwitchBot.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +import switchbot +from switchbot.devices.device import SwitchbotOperationError + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .entity import SwitchbotEntity, exception_handler + +_LOGGER = logging.getLogger(__name__) +PARALLEL_UPDATES = 0 + +SCAN_INTERVAL = timedelta(days=7) +TIME_FORMAT_12H = "12h" +TIME_FORMAT_24H = "24h" +TIME_FORMAT_OPTIONS = [TIME_FORMAT_12H, TIME_FORMAT_24H] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SwitchbotConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot select platform.""" + coordinator = entry.runtime_data + + if isinstance(coordinator.device, switchbot.SwitchbotMeterProCO2): + async_add_entities([SwitchBotMeterProCO2TimeFormatSelect(coordinator)], True) + + +class SwitchBotMeterProCO2TimeFormatSelect(SwitchbotEntity, SelectEntity): + """Select entity to set time display format on Meter Pro CO2.""" + + _attr_should_poll = True + _attr_entity_registry_enabled_default = False + _device: switchbot.SwitchbotMeterProCO2 + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "time_format" + _attr_options = TIME_FORMAT_OPTIONS + + def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: + """Initialize the select entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.base_unique_id}_time_format" + + @exception_handler + async def async_select_option(self, option: str) -> None: + """Change the time display format.""" + _LOGGER.debug("Setting time format to %s for %s", option, self._address) + is_12h_mode = option == TIME_FORMAT_12H + await self._device.set_time_display_format(is_12h_mode) + self._attr_current_option = option + self.async_write_ha_state() + + async def async_update(self) -> None: + """Fetch the latest time format from the device.""" + try: + device_time = await self._device.get_datetime() + except SwitchbotOperationError: + _LOGGER.debug( + "Failed to update time format for %s", self._address, exc_info=True + ) + return + self._attr_current_option = ( + TIME_FORMAT_12H if device_time["12h_mode"] else TIME_FORMAT_24H + ) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index d08a6279f733ca..9c9d36fd319b7f 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -265,6 +265,15 @@ } } }, + "select": { + "time_format": { + "name": "Time format", + "state": { + "12h": "12-hour (AM/PM)", + "24h": "24-hour" + } + } + }, "sensor": { "aqi_quality_level": { "name": "Air quality level", diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 6c796b320754a6..dc9bf76b7b12f0 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -222,7 +222,7 @@ def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak }, service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"5\x00d"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], - address="AA:BB:CC:DD:EE:AA", + address="AA:BB:CC:DD:EE:FF", rssi=-60, source="local", advertisement=generate_advertisement_data( @@ -233,7 +233,7 @@ def patch_async_ble_device_from_address(return_value: BluetoothServiceInfoBleak service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"5\x00d"}, service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], ), - device=generate_ble_device("AA:BB:CC:DD:EE:AA", "WoTHPc"), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "WoTHPc"), time=0, connectable=True, tx_power=-127, diff --git a/tests/components/switchbot/test_select.py b/tests/components/switchbot/test_select.py new file mode 100644 index 00000000000000..97b7c3854710db --- /dev/null +++ b/tests/components/switchbot/test_select.py @@ -0,0 +1,125 @@ +"""Tests for the switchbot select platform.""" + +from collections.abc import Callable +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import DOMAIN, WOMETERTHPC_SERVICE_INFO + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + ("mode", "expected_state"), + [ + (False, "24h"), + (True, "12h"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_time_format_select_initial_state( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + mode: bool, + expected_state: str, +) -> None: + """Test the time format select entity initial state.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOMETERTHPC_SERVICE_INFO) + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + with patch( + "switchbot.SwitchbotMeterProCO2.get_datetime", + return_value={ + "12h_mode": mode, + "year": 2025, + "month": 1, + "day": 9, + "hour": 12, + "minute": 0, + "second": 0, + }, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("select.test_name_time_format") + assert state is not None + assert state.state == expected_state + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("origin_mode", "expected_state"), + [ + (False, "24h"), + (True, "12h"), + ], +) +async def test_set_time_format( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + origin_mode: bool, + expected_state: str, +) -> None: + """Test changing time format to 12h.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, WOMETERTHPC_SERVICE_INFO) + + entry = mock_entry_factory("hygrometer_co2") + entry.add_to_hass(hass) + + mock_get_datetime = AsyncMock( + return_value={ + "12h_mode": origin_mode, + "year": 2025, + "month": 1, + "day": 9, + "hour": 12, + "minute": 0, + "second": 0, + } + ) + mock_set_time_display_format = AsyncMock(return_value=True) + + with ( + patch( + "switchbot.SwitchbotMeterProCO2.get_datetime", + mock_get_datetime, + ), + patch( + "switchbot.SwitchbotMeterProCO2.set_time_display_format", + mock_set_time_display_format, + ), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.test_name_time_format", + ATTR_OPTION: expected_state, + }, + blocking=True, + ) + + mock_set_time_display_format.assert_awaited_once_with(origin_mode) + + state = hass.states.get("select.test_name_time_format") + assert state is not None + assert state.state == expected_state diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index b1f9c15ae50ff4..cc2471b27244f1 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -93,7 +93,7 @@ async def test_co2_sensor(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=DOMAIN, data={ - CONF_ADDRESS: "AA:BB:CC:DD:EE:AA", + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", CONF_NAME: "test-name", CONF_PASSWORD: "test-password", CONF_SENSOR_TYPE: "hygrometer_co2", From e8f2493ed6b0a4be5d319f89fc557b1d088361bd Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 17 Feb 2026 08:28:25 +1000 Subject: [PATCH 2/4] Fix common-modules quality scale for advantage_air (#163209) Co-authored-by: Claude Haiku 4.5 --- .../components/advantage_air/__init__.py | 37 ++---------- .../components/advantage_air/binary_sensor.py | 32 ++++++---- .../components/advantage_air/climate.py | 20 ++++--- .../components/advantage_air/coordinator.py | 59 +++++++++++++++++++ .../components/advantage_air/cover.py | 24 ++++---- .../components/advantage_air/diagnostics.py | 2 +- .../components/advantage_air/entity.py | 30 ++++++---- .../components/advantage_air/light.py | 32 +++++----- .../components/advantage_air/models.py | 17 ------ .../advantage_air/quality_scale.yaml | 13 +--- .../components/advantage_air/select.py | 18 +++--- .../components/advantage_air/sensor.py | 42 ++++++++----- .../components/advantage_air/strings.json | 5 ++ .../components/advantage_air/switch.py | 28 ++++----- .../components/advantage_air/update.py | 10 ++-- 15 files changed, 206 insertions(+), 163 deletions(-) create mode 100644 homeassistant/components/advantage_air/coordinator.py delete mode 100644 homeassistant/components/advantage_air/models.py diff --git a/homeassistant/components/advantage_air/__init__.py b/homeassistant/components/advantage_air/__init__.py index c787990f188814..4114f612fe9590 100644 --- a/homeassistant/components/advantage_air/__init__.py +++ b/homeassistant/components/advantage_air/__init__.py @@ -1,26 +1,17 @@ """Advantage Air climate integration.""" -from datetime import timedelta -import logging +from advantage_air import advantage_air -from advantage_air import ApiError, advantage_air - -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ADVANTAGE_AIR_RETRY, DOMAIN -from .models import AdvantageAirData +from .coordinator import AdvantageAirCoordinator, AdvantageAirDataConfigEntry from .services import async_setup_services -type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirData] - -ADVANTAGE_AIR_SYNC_INTERVAL = 15 PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -32,9 +23,6 @@ Platform.UPDATE, ] -_LOGGER = logging.getLogger(__name__) -REQUEST_REFRESH_DELAY = 0.5 - CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -57,27 +45,10 @@ async def async_setup_entry( retry=ADVANTAGE_AIR_RETRY, ) - async def async_get(): - try: - return await api.async_get() - except ApiError as err: - raise UpdateFailed(err) from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="Advantage Air", - update_method=async_get, - update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False - ), - ) - + coordinator = AdvantageAirCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = AdvantageAirData(coordinator, api) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py index dd306b82c8ae74..28fdaa9b7e1cf5 100644 --- a/homeassistant/components/advantage_air/binary_sensor.py +++ b/homeassistant/components/advantage_air/binary_sensor.py @@ -11,8 +11,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity -from .models import AdvantageAirData PARALLEL_UPDATES = 0 @@ -24,19 +24,23 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir Binary Sensor platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[BinarySensorEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): - entities.append(AdvantageAirFilter(instance, ac_key)) + entities.append(AdvantageAirFilter(coordinator, ac_key)) for zone_key, zone in ac_device["zones"].items(): # Only add motion sensor when motion is enabled if zone["motionConfig"] >= 2: - entities.append(AdvantageAirZoneMotion(instance, ac_key, zone_key)) + entities.append( + AdvantageAirZoneMotion(coordinator, ac_key, zone_key) + ) # Only add MyZone if it is available if zone["type"] != 0: - entities.append(AdvantageAirZoneMyZone(instance, ac_key, zone_key)) + entities.append( + AdvantageAirZoneMyZone(coordinator, ac_key, zone_key) + ) async_add_entities(entities) @@ -47,9 +51,9 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_name = "Filter" - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air Filter sensor.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-filter" @property @@ -63,9 +67,11 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity): _attr_device_class = BinarySensorDeviceClass.MOTION - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone Motion sensor.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = f"{self._zone['name']} motion" self._attr_unique_id += "-motion" @@ -81,9 +87,11 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone MyZone sensor.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = f"{self._zone['name']} myZone" self._attr_unique_id += "-myzone" diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index 1d593c5c3c853a..938bcb469a6247 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -31,8 +31,8 @@ ADVANTAGE_AIR_STATE_ON, ADVANTAGE_AIR_STATE_OPEN, ) +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity -from .models import AdvantageAirData ADVANTAGE_AIR_HVAC_MODES = { "heat": HVACMode.HEAT, @@ -90,16 +90,16 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir climate platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[ClimateEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): - entities.append(AdvantageAirAC(instance, ac_key)) + entities.append(AdvantageAirAC(coordinator, ac_key)) for zone_key, zone in ac_device["zones"].items(): # Only add zone climate control when zone is in temperature control if zone["type"] > 0: - entities.append(AdvantageAirZone(instance, ac_key, zone_key)) + entities.append(AdvantageAirZone(coordinator, ac_key, zone_key)) async_add_entities(entities) @@ -114,9 +114,9 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): _attr_name = None _support_preset = ClimateEntityFeature(0) - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an AdvantageAir AC unit.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_preset_modes = [ADVANTAGE_AIR_MYZONE] @@ -282,9 +282,11 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity): _attr_max_temp = 32 _attr_min_temp = 16 - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an AdvantageAir Zone control.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = self._zone["name"] @property diff --git a/homeassistant/components/advantage_air/coordinator.py b/homeassistant/components/advantage_air/coordinator.py new file mode 100644 index 00000000000000..54628d4f4c38a2 --- /dev/null +++ b/homeassistant/components/advantage_air/coordinator.py @@ -0,0 +1,59 @@ +"""Coordinator for the Advantage Air integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from advantage_air import ApiError, advantage_air + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +ADVANTAGE_AIR_SYNC_INTERVAL = 15 +REQUEST_REFRESH_DELAY = 0.5 + +_LOGGER = logging.getLogger(__name__) + +type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirCoordinator] + + +class AdvantageAirCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Advantage Air coordinator.""" + + config_entry: AdvantageAirDataConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: AdvantageAirDataConfigEntry, + api: advantage_air, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="Advantage Air", + update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL), + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from the API.""" + try: + return await self.api.async_get() + except ApiError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": str(err)}, + ) from err diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index e764d484128a7f..50e9b7b59dee4f 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -13,8 +13,8 @@ from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity -from .models import AdvantageAirData PARALLEL_UPDATES = 0 @@ -26,24 +26,24 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir cover platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[CoverEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): for zone_key, zone in ac_device["zones"].items(): # Only add zone vent controls when zone in vent control mode. if zone["type"] == 0: - entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) - if things := instance.coordinator.data.get("myThings"): + entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key)) + if things := coordinator.data.get("myThings"): for thing in things["things"].values(): if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2" entities.append( - AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND) + AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.BLIND) ) elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door" entities.append( - AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE) + AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.GARAGE) ) async_add_entities(entities) @@ -58,9 +58,11 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone Vent.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = self._zone["name"] @property @@ -106,12 +108,12 @@ class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity): def __init__( self, - instance: AdvantageAirData, + coordinator: AdvantageAirCoordinator, thing: dict[str, Any], device_class: CoverDeviceClass, ) -> None: """Initialize an Advantage Air Things Cover.""" - super().__init__(instance, thing) + super().__init__(coordinator, thing) self._attr_device_class = device_class @property diff --git a/homeassistant/components/advantage_air/diagnostics.py b/homeassistant/components/advantage_air/diagnostics.py index 8d998d1ee90c5c..d15ce57df5ebc2 100644 --- a/homeassistant/components/advantage_air/diagnostics.py +++ b/homeassistant/components/advantage_air/diagnostics.py @@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data = config_entry.runtime_data.coordinator.data + data = config_entry.runtime_data.data # Return only the relevant children return { diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index be2135e4767b09..08e9ddb4f1295b 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -9,17 +9,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .models import AdvantageAirData +from .coordinator import AdvantageAirCoordinator -class AdvantageAirEntity(CoordinatorEntity): +class AdvantageAirEntity(CoordinatorEntity[AdvantageAirCoordinator]): """Parent class for Advantage Air Entities.""" _attr_has_entity_name = True - def __init__(self, instance: AdvantageAirData) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator) -> None: """Initialize common aspects of an Advantage Air entity.""" - super().__init__(instance.coordinator) + super().__init__(coordinator) self._attr_unique_id: str = self.coordinator.data["system"]["rid"] def update_handle_factory(self, func, *keys): @@ -41,9 +41,9 @@ async def update_handle(*values): class AdvantageAirAcEntity(AdvantageAirEntity): """Parent class for Advantage Air AC Entities.""" - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize common aspects of an Advantage Air ac entity.""" - super().__init__(instance) + super().__init__(coordinator) self.ac_key: str = ac_key self._attr_unique_id += f"-{ac_key}" @@ -56,7 +56,7 @@ def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"], ) self.async_update_ac = self.update_handle_factory( - instance.api.aircon.async_update_ac, self.ac_key + coordinator.api.aircon.async_update_ac, self.ac_key ) @property @@ -73,14 +73,16 @@ def _myzone(self) -> dict[str, Any] | None: class AdvantageAirZoneEntity(AdvantageAirAcEntity): """Parent class for Advantage Air Zone Entities.""" - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize common aspects of an Advantage Air zone entity.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self.zone_key: str = zone_key self._attr_unique_id += f"-{zone_key}" self.async_update_zone = self.update_handle_factory( - instance.api.aircon.async_update_zone, self.ac_key, self.zone_key + coordinator.api.aircon.async_update_zone, self.ac_key, self.zone_key ) @property @@ -93,9 +95,11 @@ class AdvantageAirThingEntity(AdvantageAirEntity): _attr_name = None - def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, thing: dict[str, Any] + ) -> None: """Initialize common aspects of an Advantage Air Things entity.""" - super().__init__(instance) + super().__init__(coordinator) self._id = thing["id"] self._attr_unique_id += f"-{self._id}" @@ -108,7 +112,7 @@ def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None: name=thing["name"], ) self.async_update_value = self.update_handle_factory( - instance.api.things.async_update_value, self._id + coordinator.api.things.async_update_value, self._id ) @property diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py index 9708adbc1f7315..6ca26e973f0ae3 100644 --- a/homeassistant/components/advantage_air/light.py +++ b/homeassistant/components/advantage_air/light.py @@ -9,8 +9,8 @@ from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirEntity, AdvantageAirThingEntity -from .models import AdvantageAirData async def async_setup_entry( @@ -20,21 +20,21 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir light platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[LightEntity] = [] - if my_lights := instance.coordinator.data.get("myLights"): + if my_lights := coordinator.data.get("myLights"): for light in my_lights["lights"].values(): if light.get("relay"): - entities.append(AdvantageAirLight(instance, light)) + entities.append(AdvantageAirLight(coordinator, light)) else: - entities.append(AdvantageAirLightDimmable(instance, light)) - if things := instance.coordinator.data.get("myThings"): + entities.append(AdvantageAirLightDimmable(coordinator, light)) + if things := coordinator.data.get("myThings"): for thing in things["things"].values(): if thing["channelDipState"] == 4: # 4 = "Light (on/off)"" - entities.append(AdvantageAirThingLight(instance, thing)) + entities.append(AdvantageAirThingLight(coordinator, thing)) elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)"" - entities.append(AdvantageAirThingLightDimmable(instance, thing)) + entities.append(AdvantageAirThingLightDimmable(coordinator, thing)) async_add_entities(entities) @@ -45,9 +45,11 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} _attr_name = None - def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, light: dict[str, Any] + ) -> None: """Initialize an Advantage Air Light.""" - super().__init__(instance) + super().__init__(coordinator) self._id: str = light["id"] self._attr_unique_id += f"-{self._id}" @@ -59,7 +61,7 @@ def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: name=light["name"], ) self.async_update_state = self.update_handle_factory( - instance.api.lights.async_update_state, self._id + coordinator.api.lights.async_update_state, self._id ) @property @@ -87,11 +89,13 @@ class AdvantageAirLightDimmable(AdvantageAirLight): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, light: dict[str, Any] + ) -> None: """Initialize an Advantage Air Dimmable Light.""" - super().__init__(instance, light) + super().__init__(coordinator, light) self.async_update_value = self.update_handle_factory( - instance.api.lights.async_update_value, self._id + coordinator.api.lights.async_update_value, self._id ) @property diff --git a/homeassistant/components/advantage_air/models.py b/homeassistant/components/advantage_air/models.py deleted file mode 100644 index 77135644d11597..00000000000000 --- a/homeassistant/components/advantage_air/models.py +++ /dev/null @@ -1,17 +0,0 @@ -"""The Advantage Air integration models.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from advantage_air import advantage_air - -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - - -@dataclass -class AdvantageAirData: - """Data for the Advantage Air integration.""" - - coordinator: DataUpdateCoordinator - api: advantage_air diff --git a/homeassistant/components/advantage_air/quality_scale.yaml b/homeassistant/components/advantage_air/quality_scale.yaml index 9c87ce4213ed5e..bc1ef11493c4aa 100644 --- a/homeassistant/components/advantage_air/quality_scale.yaml +++ b/homeassistant/components/advantage_air/quality_scale.yaml @@ -5,12 +5,7 @@ rules: comment: https://developers.home-assistant.io/blog/2025/09/25/entity-services-api-changes/ appropriate-polling: done brands: done - common-modules: - status: todo - comment: | - Move coordinator from __init__.py to coordinator.py. - Consider using entity descriptions for binary_sensor and switch. - Consider simplifying climate supported features flow. + common-modules: done config-flow-test-coverage: status: todo comment: | @@ -33,9 +28,7 @@ rules: comment: Entities do not explicitly subscribe to events. entity-unique-id: done has-entity-name: done - runtime-data: - status: done - comment: Consider extending coordinator to access API via coordinator and remove extra dataclass. + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done @@ -92,7 +85,7 @@ rules: entity-translations: todo exception-translations: status: todo - comment: UpdateFailed in the coordinator + comment: HomeAssistantError in entity.py and ServiceValidationError in climate.py icon-translations: todo reconfiguration-flow: todo repair-issues: diff --git a/homeassistant/components/advantage_air/select.py b/homeassistant/components/advantage_air/select.py index 320bfd35abaaa6..a8abca25d071de 100644 --- a/homeassistant/components/advantage_air/select.py +++ b/homeassistant/components/advantage_air/select.py @@ -5,8 +5,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AdvantageAirDataConfigEntry +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity -from .models import AdvantageAirData ADVANTAGE_AIR_INACTIVE = "Inactive" @@ -18,10 +18,12 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir select platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data - if aircons := instance.coordinator.data.get("aircons"): - async_add_entities(AdvantageAirMyZone(instance, ac_key) for ac_key in aircons) + if aircons := coordinator.data.get("aircons"): + async_add_entities( + AdvantageAirMyZone(coordinator, ac_key) for ac_key in aircons + ) class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): @@ -30,16 +32,16 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): _attr_icon = "mdi:home-thermometer" _attr_name = "MyZone" - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air MyZone control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-myzone" self._attr_options = [ADVANTAGE_AIR_INACTIVE] self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE} self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0} - if "aircons" in instance.coordinator.data: - for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values(): + if "aircons" in coordinator.data: + for zone in coordinator.data["aircons"][ac_key]["zones"].values(): if zone["type"] > 0: self._name_to_number[zone["name"]] = zone["number"] self._number_to_name[zone["number"]] = zone["name"] diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py index 31be475aeddd4b..59d72a7bacf36b 100644 --- a/homeassistant/components/advantage_air/sensor.py +++ b/homeassistant/components/advantage_air/sensor.py @@ -16,8 +16,8 @@ from . import AdvantageAirDataConfigEntry from .const import ADVANTAGE_AIR_STATE_OPEN +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity -from .models import AdvantageAirData ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes" ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min" @@ -32,21 +32,23 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir sensor platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[SensorEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): - entities.append(AdvantageAirTimeTo(instance, ac_key, "On")) - entities.append(AdvantageAirTimeTo(instance, ac_key, "Off")) + entities.append(AdvantageAirTimeTo(coordinator, ac_key, "On")) + entities.append(AdvantageAirTimeTo(coordinator, ac_key, "Off")) for zone_key, zone in ac_device["zones"].items(): # Only show damper and temp sensors when zone is in temperature control if zone["type"] != 0: - entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key)) - entities.append(AdvantageAirZoneTemp(instance, ac_key, zone_key)) + entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key)) + entities.append(AdvantageAirZoneTemp(coordinator, ac_key, zone_key)) # Only show wireless signal strength sensors when using wireless sensors if zone["rssi"] > 0: - entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key)) + entities.append( + AdvantageAirZoneSignal(coordinator, ac_key, zone_key) + ) async_add_entities(entities) @@ -56,9 +58,11 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity): _attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, action: str + ) -> None: """Initialize the Advantage Air timer control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self.action = action self._time_key = f"countDownTo{action}" self._attr_name = f"Time to {action}" @@ -89,9 +93,11 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone Vent Sensor.""" - super().__init__(instance, ac_key, zone_key=zone_key) + super().__init__(coordinator, ac_key, zone_key=zone_key) self._attr_name = f"{self._zone['name']} vent" self._attr_unique_id += "-vent" @@ -117,9 +123,11 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity): _attr_state_class = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone wireless signal sensor.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = f"{self._zone['name']} signal" self._attr_unique_id += "-signal" @@ -151,9 +159,11 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity): _attr_entity_registry_enabled_default = False _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None: + def __init__( + self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str + ) -> None: """Initialize an Advantage Air Zone Temp Sensor.""" - super().__init__(instance, ac_key, zone_key) + super().__init__(coordinator, ac_key, zone_key) self._attr_name = f"{self._zone['name']} temperature" self._attr_unique_id += "-temp" diff --git a/homeassistant/components/advantage_air/strings.json b/homeassistant/components/advantage_air/strings.json index 719356e0cf2b8e..80e8b15de98c52 100644 --- a/homeassistant/components/advantage_air/strings.json +++ b/homeassistant/components/advantage_air/strings.json @@ -17,6 +17,11 @@ } } }, + "exceptions": { + "update_failed": { + "message": "An error occurred while updating from the Advantage Air API: {error}" + } + }, "services": { "set_time_to": { "description": "Controls timers to turn the system on or off after a set number of minutes.", diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py index 8560c9a913887e..d75c14c20e9c01 100644 --- a/homeassistant/components/advantage_air/switch.py +++ b/homeassistant/components/advantage_air/switch.py @@ -13,8 +13,8 @@ ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_ON, ) +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity -from .models import AdvantageAirData async def async_setup_entry( @@ -24,20 +24,20 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir switch platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data entities: list[SwitchEntity] = [] - if aircons := instance.coordinator.data.get("aircons"): + if aircons := coordinator.data.get("aircons"): for ac_key, ac_device in aircons.items(): if ac_device["info"]["freshAirStatus"] != "none": - entities.append(AdvantageAirFreshAir(instance, ac_key)) + entities.append(AdvantageAirFreshAir(coordinator, ac_key)) if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: - entities.append(AdvantageAirMyFan(instance, ac_key)) + entities.append(AdvantageAirMyFan(coordinator, ac_key)) if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]: - entities.append(AdvantageAirNightMode(instance, ac_key)) - if things := instance.coordinator.data.get("myThings"): + entities.append(AdvantageAirNightMode(coordinator, ac_key)) + if things := coordinator.data.get("myThings"): entities.extend( - AdvantageAirRelay(instance, thing) + AdvantageAirRelay(coordinator, thing) for thing in things["things"].values() if thing["channelDipState"] == 8 # 8 = Other relay ) @@ -51,9 +51,9 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity): _attr_name = "Fresh air" _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air fresh air control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-freshair" @property @@ -77,9 +77,9 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity): _attr_name = "MyFan" _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air MyFan control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-myfan" @property @@ -103,9 +103,9 @@ class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity): _attr_name = "MySleep$aver" _attr_device_class = SwitchDeviceClass.SWITCH - def __init__(self, instance: AdvantageAirData, ac_key: str) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: """Initialize an Advantage Air Night Mode control.""" - super().__init__(instance, ac_key) + super().__init__(coordinator, ac_key) self._attr_unique_id += "-nightmode" @property diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py index 68df31142e30bf..d4903a54839f92 100644 --- a/homeassistant/components/advantage_air/update.py +++ b/homeassistant/components/advantage_air/update.py @@ -7,8 +7,8 @@ from . import AdvantageAirDataConfigEntry from .const import DOMAIN +from .coordinator import AdvantageAirCoordinator from .entity import AdvantageAirEntity -from .models import AdvantageAirData async def async_setup_entry( @@ -18,9 +18,9 @@ async def async_setup_entry( ) -> None: """Set up AdvantageAir update platform.""" - instance = config_entry.runtime_data + coordinator = config_entry.runtime_data - async_add_entities([AdvantageAirApp(instance)]) + async_add_entities([AdvantageAirApp(coordinator)]) class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): @@ -28,9 +28,9 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): _attr_name = "App" - def __init__(self, instance: AdvantageAirData) -> None: + def __init__(self, coordinator: AdvantageAirCoordinator) -> None: """Initialize the Advantage Air App.""" - super().__init__(instance) + super().__init__(coordinator) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])}, manufacturer="Advantage Air", From 52d645e4bfdfbf6bc3f29d4ef56ca3d2590f449e Mon Sep 17 00:00:00 2001 From: Hai-Nam Nguyen Date: Mon, 16 Feb 2026 23:38:44 +0100 Subject: [PATCH 3/4] Hypontech micro invertors support via Hyponcloud (#159442) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/hypontech/__init__.py | 47 +++ .../components/hypontech/config_flow.py | 76 ++++ homeassistant/components/hypontech/const.py | 7 + .../components/hypontech/coordinator.py | 62 ++++ homeassistant/components/hypontech/entity.py | 53 +++ .../components/hypontech/manifest.json | 11 + .../components/hypontech/quality_scale.yaml | 60 +++ homeassistant/components/hypontech/sensor.py | 152 ++++++++ .../components/hypontech/strings.json | 52 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/hypontech/__init__.py | 13 + tests/components/hypontech/conftest.py | 92 +++++ .../hypontech/fixtures/admin_info.json | 44 +++ .../hypontech/fixtures/inverters.json | 78 ++++ .../components/hypontech/fixtures/login.json | 8 + .../hypontech/fixtures/overview.json | 27 ++ .../hypontech/fixtures/plant_list.json | 29 ++ .../hypontech/snapshots/test_sensor.ambr | 343 ++++++++++++++++++ .../components/hypontech/test_config_flow.py | 177 +++++++++ tests/components/hypontech/test_init.py | 51 +++ tests/components/hypontech/test_sensor.py | 27 ++ 27 files changed, 1435 insertions(+) create mode 100644 homeassistant/components/hypontech/__init__.py create mode 100644 homeassistant/components/hypontech/config_flow.py create mode 100644 homeassistant/components/hypontech/const.py create mode 100644 homeassistant/components/hypontech/coordinator.py create mode 100644 homeassistant/components/hypontech/entity.py create mode 100644 homeassistant/components/hypontech/manifest.json create mode 100644 homeassistant/components/hypontech/quality_scale.yaml create mode 100644 homeassistant/components/hypontech/sensor.py create mode 100644 homeassistant/components/hypontech/strings.json create mode 100644 tests/components/hypontech/__init__.py create mode 100644 tests/components/hypontech/conftest.py create mode 100644 tests/components/hypontech/fixtures/admin_info.json create mode 100644 tests/components/hypontech/fixtures/inverters.json create mode 100644 tests/components/hypontech/fixtures/login.json create mode 100644 tests/components/hypontech/fixtures/overview.json create mode 100644 tests/components/hypontech/fixtures/plant_list.json create mode 100644 tests/components/hypontech/snapshots/test_sensor.ambr create mode 100644 tests/components/hypontech/test_config_flow.py create mode 100644 tests/components/hypontech/test_init.py create mode 100644 tests/components/hypontech/test_sensor.py diff --git a/.strict-typing b/.strict-typing index fa8588e3dc55db..2ea93fa6fbc12c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -275,6 +275,7 @@ homeassistant.components.humidifier.* homeassistant.components.husqvarna_automower.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* +homeassistant.components.hypontech.* homeassistant.components.ibeacon.* homeassistant.components.idasen_desk.* homeassistant.components.image.* diff --git a/CODEOWNERS b/CODEOWNERS index c53d65b595732a..bad799a69e6e1e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -753,6 +753,8 @@ build.json @home-assistant/supervisor /tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy +/homeassistant/components/hypontech/ @jcisio +/tests/components/hypontech/ @jcisio /homeassistant/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK /homeassistant/components/iammeter/ @lewei50 diff --git a/homeassistant/components/hypontech/__init__.py b/homeassistant/components/hypontech/__init__.py new file mode 100644 index 00000000000000..ba0c0e5d459698 --- /dev/null +++ b/homeassistant/components/hypontech/__init__.py @@ -0,0 +1,47 @@ +"""The Hypontech Cloud integration.""" + +from __future__ import annotations + +from hyponcloud import AuthenticationError, HyponCloud, RequestError + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .coordinator import HypontechConfigEntry, HypontechDataCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: HypontechConfigEntry) -> bool: + """Set up Hypontech Cloud from a config entry.""" + session = async_get_clientsession(hass) + hypontech_cloud = HyponCloud( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + session, + ) + try: + await hypontech_cloud.connect() + except AuthenticationError as ex: + raise ConfigEntryAuthFailed("Authentication failed for Hypontech Cloud") from ex + except (RequestError, TimeoutError, ConnectionError) as ex: + raise ConfigEntryNotReady("Cannot connect to Hypontech Cloud") from ex + + assert entry.unique_id + coordinator = HypontechDataCoordinator( + hass, entry, hypontech_cloud, entry.unique_id + ) + 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: HypontechConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/hypontech/config_flow.py b/homeassistant/components/hypontech/config_flow.py new file mode 100644 index 00000000000000..a0f233b0039167 --- /dev/null +++ b/homeassistant/components/hypontech/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for the Hypontech Cloud integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from hyponcloud import AuthenticationError, HyponCloud +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class HypontechConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hypontech Cloud.""" + + 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: + session = async_get_clientsession(self.hass) + hypon = HyponCloud( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session + ) + try: + await hypon.connect() + admin_info = await hypon.get_admin_info() + except AuthenticationError: + errors["base"] = "invalid_auth" + except TimeoutError, ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(admin_info.id) + if self.source == SOURCE_USER: + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_USERNAME], + data=user_input, + ) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauthentication.""" + return await self.async_step_user() diff --git a/homeassistant/components/hypontech/const.py b/homeassistant/components/hypontech/const.py new file mode 100644 index 00000000000000..4f290ee882d460 --- /dev/null +++ b/homeassistant/components/hypontech/const.py @@ -0,0 +1,7 @@ +"""Constants for the Hypontech Cloud integration.""" + +from logging import Logger, getLogger + +DOMAIN = "hypontech" + +LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/hypontech/coordinator.py b/homeassistant/components/hypontech/coordinator.py new file mode 100644 index 00000000000000..b3cae5d6e5de3f --- /dev/null +++ b/homeassistant/components/hypontech/coordinator.py @@ -0,0 +1,62 @@ +"""The coordinator for Hypontech Cloud integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta + +from hyponcloud import HyponCloud, OverviewData, PlantData, RequestError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +@dataclass +class HypontechCoordinatorData: + """Store coordinator data.""" + + overview: OverviewData + plants: dict[str, PlantData] + + +type HypontechConfigEntry = ConfigEntry[HypontechDataCoordinator] + + +class HypontechDataCoordinator(DataUpdateCoordinator[HypontechCoordinatorData]): + """Coordinator used for all sensors.""" + + config_entry: HypontechConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HypontechConfigEntry, + api: HyponCloud, + account_id: str, + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="Hypontech Data", + update_interval=timedelta(seconds=60), + ) + self.api = api + self.account_id = account_id + + async def _async_update_data(self) -> HypontechCoordinatorData: + try: + overview = await self.api.get_overview() + plants = await self.api.get_list() + except RequestError as ex: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="connection_error" + ) from ex + return HypontechCoordinatorData( + overview=overview, + plants={plant.plant_id: plant for plant in plants}, + ) diff --git a/homeassistant/components/hypontech/entity.py b/homeassistant/components/hypontech/entity.py new file mode 100644 index 00000000000000..a8abb23cf09c8b --- /dev/null +++ b/homeassistant/components/hypontech/entity.py @@ -0,0 +1,53 @@ +"""Base entity for the Hypontech Cloud integration.""" + +from __future__ import annotations + +from hyponcloud import PlantData + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HypontechDataCoordinator + + +class HypontechEntity(CoordinatorEntity[HypontechDataCoordinator]): + """Base entity for Hypontech Cloud.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HypontechDataCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.account_id)}, + name="Overview", + manufacturer="Hypontech", + ) + + +class HypontechPlantEntity(CoordinatorEntity[HypontechDataCoordinator]): + """Base entity for Hypontech Cloud plant.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HypontechDataCoordinator, plant_id: str) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.plant_id = plant_id + plant = coordinator.data.plants[plant_id] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, plant_id)}, + name=plant.plant_name, + manufacturer="Hypontech", + ) + + @property + def plant(self) -> PlantData: + """Return the plant data.""" + return self.coordinator.data.plants[self.plant_id] + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.plant_id in self.coordinator.data.plants diff --git a/homeassistant/components/hypontech/manifest.json b/homeassistant/components/hypontech/manifest.json new file mode 100644 index 00000000000000..8701e493192b70 --- /dev/null +++ b/homeassistant/components/hypontech/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "hypontech", + "name": "Hypontech Cloud", + "codeowners": ["@jcisio"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/hypontech", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["hyponcloud==0.3.0"] +} diff --git a/homeassistant/components/hypontech/quality_scale.yaml b/homeassistant/components/hypontech/quality_scale.yaml new file mode 100644 index 00000000000000..cd76f521036e3f --- /dev/null +++ b/homeassistant/components/hypontech/quality_scale.yaml @@ -0,0 +1,60 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + 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: todo + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: done + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/hypontech/sensor.py b/homeassistant/components/hypontech/sensor.py new file mode 100644 index 00000000000000..a85e77174d0989 --- /dev/null +++ b/homeassistant/components/hypontech/sensor.py @@ -0,0 +1,152 @@ +"""The read-only sensors for Hypontech integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from hyponcloud import OverviewData, PlantData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HypontechConfigEntry, HypontechDataCoordinator +from .entity import HypontechEntity, HypontechPlantEntity + + +@dataclass(frozen=True, kw_only=True) +class HypontechSensorDescription(SensorEntityDescription): + """Describes Hypontech overview sensor entity.""" + + value_fn: Callable[[OverviewData], float | None] + + +@dataclass(frozen=True, kw_only=True) +class HypontechPlantSensorDescription(SensorEntityDescription): + """Describes Hypontech plant sensor entity.""" + + value_fn: Callable[[PlantData], float | None] + + +OVERVIEW_SENSORS: tuple[HypontechSensorDescription, ...] = ( + HypontechSensorDescription( + key="pv_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.power, + ), + HypontechSensorDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_total, + ), + HypontechSensorDescription( + key="today_energy", + translation_key="today_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_today, + ), +) + +PLANT_SENSORS: tuple[HypontechPlantSensorDescription, ...] = ( + HypontechPlantSensorDescription( + key="pv_power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda c: c.power, + ), + HypontechPlantSensorDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_total, + ), + HypontechPlantSensorDescription( + key="today_energy", + translation_key="today_energy", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda c: c.e_today, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HypontechConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + + entities: list[SensorEntity] = [ + HypontechOverviewSensor(coordinator, desc) for desc in OVERVIEW_SENSORS + ] + + entities.extend( + HypontechPlantSensor(coordinator, plant_id, desc) + for plant_id in coordinator.data.plants + for desc in PLANT_SENSORS + ) + + async_add_entities(entities) + + +class HypontechOverviewSensor(HypontechEntity, SensorEntity): + """Class describing Hypontech overview sensor entities.""" + + entity_description: HypontechSensorDescription + + def __init__( + self, + coordinator: HypontechDataCoordinator, + description: HypontechSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.account_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data.overview) + + +class HypontechPlantSensor(HypontechPlantEntity, SensorEntity): + """Class describing Hypontech plant sensor entities.""" + + entity_description: HypontechPlantSensorDescription + + def __init__( + self, + coordinator: HypontechDataCoordinator, + plant_id: str, + description: HypontechPlantSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, plant_id) + self.entity_description = description + self._attr_unique_id = f"{plant_id}_{description.key}" + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.plant) diff --git a/homeassistant/components/hypontech/strings.json b/homeassistant/components/hypontech/strings.json new file mode 100644 index 00000000000000..b2d18800fe0f01 --- /dev/null +++ b/homeassistant/components/hypontech/strings.json @@ -0,0 +1,52 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "wrong_account": "The provided credentials are for a different Hypontech Cloud account." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "[%key:component::hypontech::config::step::user::data_description::password%]", + "username": "[%key:component::hypontech::config::step::user::data_description::username%]" + }, + "description": "Your Hypontech Cloud credentials have expired. Please re-enter your credentials to continue using this integration." + }, + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "Your Hypontech Cloud account password.", + "username": "Your Hypontech Cloud account username." + } + } + } + }, + "entity": { + "sensor": { + "lifetime_energy": { + "name": "Lifetime energy" + }, + "today_energy": { + "name": "Today energy" + } + } + }, + "exceptions": { + "connection_error": { + "message": "Failed to connect to Hypontech Cloud. Maybe you make too frequent connection from multiple devices in your network." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 04902a57f0252e..9bcc030329a647 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -314,6 +314,7 @@ "hvv_departures", "hydrawise", "hyperion", + "hypontech", "ialarm", "iaqualink", "ibeacon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e111bae54b2e35..e7935ab74279d6 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2990,6 +2990,12 @@ "config_flow": true, "iot_class": "local_push" }, + "hypontech": { + "name": "Hypontech Cloud", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "ialarm": { "name": "Antifurto365 iAlarm", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 5a9058326c6dc6..6ace8e21ce45ee 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2506,6 +2506,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.hypontech.*] +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.ibeacon.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 03c4c4b395b5dd..55bdf620cd8f1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1255,6 +1255,9 @@ huum==0.8.1 # homeassistant.components.hyperion hyperion-py==0.7.6 +# homeassistant.components.hypontech +hyponcloud==0.3.0 + # homeassistant.components.iammeter iammeter==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2fd188a6df547f..9c8c5ab7b1ecbb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1113,6 +1113,9 @@ huum==0.8.1 # homeassistant.components.hyperion hyperion-py==0.7.6 +# homeassistant.components.hypontech +hyponcloud==0.3.0 + # homeassistant.components.iaqualink iaqualink==0.6.0 diff --git a/tests/components/hypontech/__init__.py b/tests/components/hypontech/__init__.py new file mode 100644 index 00000000000000..3dcb7d7ecba7dd --- /dev/null +++ b/tests/components/hypontech/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Hypontech Cloud integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/hypontech/conftest.py b/tests/components/hypontech/conftest.py new file mode 100644 index 00000000000000..6007696c39ef75 --- /dev/null +++ b/tests/components/hypontech/conftest.py @@ -0,0 +1,92 @@ +"""Common fixtures for the Hypontech Cloud tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from hyponcloud import AdminInfo, InverterData, OverviewData, PlantData +import pytest + +from homeassistant.components.hypontech.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.hypontech.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", + }, + unique_id="2123456789123456789", + ) + + +@pytest.fixture +def load_overview_fixture() -> OverviewData: + """Load overview fixture data.""" + data = load_json_object_fixture("overview.json", DOMAIN) + return OverviewData.from_dict(data["data"]) + + +@pytest.fixture +def load_plant_list_fixture() -> list[PlantData]: + """Load plant list fixture data.""" + data = load_json_object_fixture("plant_list.json", DOMAIN) + return [PlantData.from_dict(item) for item in data["data"]] + + +@pytest.fixture +def load_inverters_fixture() -> list[InverterData]: + """Load inverters fixture data.""" + data = load_json_object_fixture("inverters.json", DOMAIN) + return [InverterData.from_dict(item) for item in data["data"]] + + +@pytest.fixture +def load_admin_info_fixture() -> AdminInfo: + """Load admin info fixture data.""" + data = load_json_object_fixture("admin_info.json", DOMAIN) + admin_data = data["data"] + # Flatten nested "info" object into the main data dict + if "info" in admin_data and isinstance(admin_data["info"], dict): + info_data = admin_data.pop("info") + admin_data.update(info_data) + return AdminInfo.from_dict(admin_data) + + +@pytest.fixture +def mock_hyponcloud( + load_overview_fixture: OverviewData, + load_plant_list_fixture: list[PlantData], + load_inverters_fixture: list[InverterData], + load_admin_info_fixture: AdminInfo, +) -> Generator[AsyncMock]: + """Mock HyponCloud.""" + with ( + patch( + "homeassistant.components.hypontech.HyponCloud", autospec=True + ) as mock_hyponcloud, + patch( + "homeassistant.components.hypontech.config_flow.HyponCloud", + new=mock_hyponcloud, + ), + ): + mock_client = mock_hyponcloud.return_value + mock_client.get_admin_info.return_value = load_admin_info_fixture + mock_client.get_list.return_value = load_plant_list_fixture + mock_client.get_overview.return_value = load_overview_fixture + mock_client.get_inverters.return_value = load_inverters_fixture + yield mock_client diff --git a/tests/components/hypontech/fixtures/admin_info.json b/tests/components/hypontech/fixtures/admin_info.json new file mode 100644 index 00000000000000..c655addc21d7c0 --- /dev/null +++ b/tests/components/hypontech/fixtures/admin_info.json @@ -0,0 +1,44 @@ +{ + "data": { + "parent_name": "admin", + "role": ["End-User"], + "info": { + "last_login_time": "2026-02-17 05:32:32", + "created_at": "2025-11-30 23:17:59", + "deleted_at": "0001-01-01 00:00:00", + "country": "United States", + "address": "", + "email": "admin@example.com", + "mobile": "", + "mobile_prefix_code": "", + "login_name": "admin@example.com", + "first_name": "First Name", + "last_name": "Last Name", + "company": "", + "city": "New York", + "city_mobile_code": "", + "username": "admin@example.com", + "country_mobile_code": "001", + "photo": "", + "address2": "", + "language": "us", + "currency": "USD", + "postal_code": "", + "timezone": "America/New_York", + "last_login_ip": "10.0.0.1", + "id": "2123456789123456789", + "eid": 0, + "manufacturer": 1, + "switch_warning": 0, + "is_internal": 2, + "status": 1, + "first_login": true, + "token": "" + }, + "parent_id": "0", + "has_lower_level": false + }, + "message": "ok", + "time": 1771277551, + "code": 20000 +} diff --git a/tests/components/hypontech/fixtures/inverters.json b/tests/components/hypontech/fixtures/inverters.json new file mode 100644 index 00000000000000..34fb082f6a0785 --- /dev/null +++ b/tests/components/hypontech/fixtures/inverters.json @@ -0,0 +1,78 @@ +{ + "data": [ + { + "gateway": { + "time": "2026-02-16T18:04:53+01:00", + "sn": "P16000A024500000", + "model": "COM-WF", + "status": "offline", + "push_time": 60, + "pid": "0" + }, + "plant_name": "Balcon", + "sn": "P16000A024500000", + "gateway_sn": "P16000A024500000", + "status": "offline", + "model": "HMS-1600W", + "software_version": "V1.0.0.10", + "lcd_version": "V0.0.0.0", + "afci_version": "V0.0.0.0", + "afci_version0": "", + "afci_version1": "", + "afci_version2": "", + "time": "2026-02-16T18:04:50+01:00", + "spn": "H910-07100-10", + "port": [ + { + "sn": "P16000A024500000", + "id": "0", + "x": -1, + "y": -1, + "port": 1 + }, + { + "sn": "P16000A024500000", + "id": "0", + "x": -1, + "y": -1, + "port": 2 + }, + { + "sn": "P16000A024500000", + "id": "0", + "x": -1, + "y": -1, + "port": 3 + }, + { + "sn": "P16000A024500000", + "id": "0", + "x": -1, + "y": -1, + "port": 4 + } + ], + "power": 0, + "eid": "0", + "device_type": "2", + "fault": 0, + "warning": 0, + "plant_id": "1123456789123456789", + "modbus": 1, + "e_total": 48.01, + "e_today": 1.54, + "property": 1, + "nick_name": "", + "com": 0, + "system_connect_mode": 0, + "third_active_power": 0, + "third_meter_energy": 0, + "today_generation_third": 0 + } + ], + "message": "ok", + "time": 1771277551, + "totalPage": 1, + "totalCount": 1, + "code": 20000 +} diff --git a/tests/components/hypontech/fixtures/login.json b/tests/components/hypontech/fixtures/login.json new file mode 100644 index 00000000000000..97e2e62446b2a5 --- /dev/null +++ b/tests/components/hypontech/fixtures/login.json @@ -0,0 +1,8 @@ +{ + "data": { + "token": "12345678905a17049548ba99c75081e6" + }, + "message": "ok", + "time": 1771277551, + "code": 20000 +} diff --git a/tests/components/hypontech/fixtures/overview.json b/tests/components/hypontech/fixtures/overview.json new file mode 100644 index 00000000000000..8a58b7bf3182a0 --- /dev/null +++ b/tests/components/hypontech/fixtures/overview.json @@ -0,0 +1,27 @@ +{ + "data": { + "company": "W", + "capacity_company": "KW", + "e_total": 48.01, + "e_today": 1.54, + "total_co2": 0.03, + "total_tree": 1.73, + "power": 0, + "normal_dev_num": 0, + "offline_dev_num": 1, + "fault_dev_num": 0, + "wait_dev_num": 0, + "percent": 0, + "capacity": 1.6, + "earning": [ + { + "currency": "USD", + "today": 0.95, + "total": 0.95 + } + ] + }, + "message": "ok", + "time": 1771277551, + "code": 20000 +} diff --git a/tests/components/hypontech/fixtures/plant_list.json b/tests/components/hypontech/fixtures/plant_list.json new file mode 100644 index 00000000000000..bc8cfb5a6b8d7d --- /dev/null +++ b/tests/components/hypontech/fixtures/plant_list.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "status": "offline", + "time": "2026-02-16T18:04:50+01:00", + "plant_name": "Balcon", + "plant_type": "PvGrid", + "photo": "", + "owner_name": "admin@example.com", + "country": "United States", + "city": "New York", + "e_today": 1.54, + "e_total": 48, + "power": 0, + "owner_id": "2123456789123456789", + "plant_id": "1123456789123456789", + "eid": 0, + "top": 0, + "micro": 1, + "property": 1, + "kwhimp": 0 + } + ], + "message": "ok", + "time": 1771277551, + "totalPage": 1, + "totalCount": 1, + "code": 20000 +} diff --git a/tests/components/hypontech/snapshots/test_sensor.ambr b/tests/components/hypontech/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..9feac1ad314a93 --- /dev/null +++ b/tests/components/hypontech/snapshots/test_sensor.ambr @@ -0,0 +1,343 @@ +# serializer version: 1 +# name: test_sensors[sensor.balcon_lifetime_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.balcon_lifetime_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lifetime energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1123456789123456789_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_lifetime_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Balcon Lifetime energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_lifetime_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.0', + }) +# --- +# name: test_sensors[sensor.balcon_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.balcon_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1123456789123456789_pv_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Balcon Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.balcon_today_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.balcon_today_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Today energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today energy', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'today_energy', + 'unique_id': '1123456789123456789_today_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.balcon_today_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Balcon Today energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.balcon_today_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.54', + }) +# --- +# name: test_sensors[sensor.overview_lifetime_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overview_lifetime_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Lifetime energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '2123456789123456789_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.overview_lifetime_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Overview Lifetime energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.overview_lifetime_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.01', + }) +# --- +# name: test_sensors[sensor.overview_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overview_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Power', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '2123456789123456789_pv_power', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.overview_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Overview Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.overview_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[sensor.overview_today_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.overview_today_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Today energy', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Today energy', + 'platform': 'hypontech', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'today_energy', + 'unique_id': '2123456789123456789_today_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.overview_today_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Overview Today energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.overview_today_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.54', + }) +# --- diff --git a/tests/components/hypontech/test_config_flow.py b/tests/components/hypontech/test_config_flow.py new file mode 100644 index 00000000000000..e35a71d72c1525 --- /dev/null +++ b/tests/components/hypontech/test_config_flow.py @@ -0,0 +1,177 @@ +"""Test the Hypontech Cloud config flow.""" + +from unittest.mock import AsyncMock + +from hyponcloud import AuthenticationError +import pytest + +from homeassistant.components.hypontech.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +TEST_USER_INPUT = { + CONF_USERNAME: "test@example.com", + CONF_PASSWORD: "test-password", +} + + +async def test_user_flow( + hass: HomeAssistant, mock_hyponcloud: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Test a successful user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert result["data"] == TEST_USER_INPUT + assert result["result"].unique_id == "2123456789123456789" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_message"), + [ + (AuthenticationError, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_setup_entry") +async def test_form_errors( + hass: HomeAssistant, + mock_hyponcloud: AsyncMock, + side_effect: Exception, + error_message: str, +) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_hyponcloud.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_message} + + mock_hyponcloud.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_INPUT, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hyponcloud: AsyncMock +) -> None: + """Test that duplicate entries are prevented based on account ID.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hyponcloud: AsyncMock +) -> None: + """Test reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_USER_INPUT, CONF_PASSWORD: "password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "password" + + +@pytest.mark.parametrize( + ("side_effect", "error_message"), + [ + (AuthenticationError, "invalid_auth"), + (ConnectionError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hyponcloud: AsyncMock, + side_effect: Exception, + error_message: str, +) -> None: + """Test reauthentication flow with errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_hyponcloud.connect.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_USER_INPUT, CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_message} + + mock_hyponcloud.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_USER_INPUT, CONF_PASSWORD: "new-password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_flow_wrong_account( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hyponcloud: AsyncMock +) -> None: + """Test reauthentication flow with wrong account.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_hyponcloud.get_admin_info.return_value.id = "different_account_id_456" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**TEST_USER_INPUT, CONF_USERNAME: "different@example.com"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/hypontech/test_init.py b/tests/components/hypontech/test_init.py new file mode 100644 index 00000000000000..b49de5954faa40 --- /dev/null +++ b/tests/components/hypontech/test_init.py @@ -0,0 +1,51 @@ +"""Test the Hypontech Cloud init.""" + +from unittest.mock import AsyncMock + +from hyponcloud import AuthenticationError, RequestError +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("side_effect", "expected_state"), + [ + (TimeoutError, ConfigEntryState.SETUP_RETRY), + (AuthenticationError, ConfigEntryState.SETUP_ERROR), + (RequestError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hyponcloud: AsyncMock, + side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup entry with timeout error.""" + mock_hyponcloud.connect.side_effect = side_effect + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is expected_state + + +async def test_setup_and_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_hyponcloud: AsyncMock, +) -> None: + """Test setup and unload of config entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/hypontech/test_sensor.py b/tests/components/hypontech/test_sensor.py new file mode 100644 index 00000000000000..7f349fcc90c6cc --- /dev/null +++ b/tests/components/hypontech/test_sensor.py @@ -0,0 +1,27 @@ +"""Tests for Hypontech sensors.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_hyponcloud: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Hypontech sensors.""" + with patch("homeassistant.components.hypontech._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From e0f39e6392c4571de13b61baf81d91c88912a8fb Mon Sep 17 00:00:00 2001 From: Andrej Friesen Date: Mon, 16 Feb 2026 23:48:15 +0100 Subject: [PATCH 4/4] Add Pressure Stall Information (PSI) to Systemmonitor integration (#151946) Co-authored-by: Franck Nijhof Co-authored-by: Joostlek --- .../components/systemmonitor/coordinator.py | 11 + .../components/systemmonitor/icons.json | 60 +++++ .../components/systemmonitor/sensor.py | 223 ++++++++++++++++++ .../components/systemmonitor/strings.json | 60 +++++ .../components/systemmonitor/util.py | 64 +++++ tests/components/systemmonitor/conftest.py | 24 +- .../snapshots/test_diagnostics.ambr | 76 ++++++ .../systemmonitor/snapshots/test_sensor.ambr | 204 ++++++++-------- tests/components/systemmonitor/test_sensor.py | 94 +++++++- 9 files changed, 706 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/systemmonitor/coordinator.py b/homeassistant/components/systemmonitor/coordinator.py index 052329ae5ff565..225940e0d44721 100644 --- a/homeassistant/components/systemmonitor/coordinator.py +++ b/homeassistant/components/systemmonitor/coordinator.py @@ -19,6 +19,7 @@ from homeassistant.util import dt as dt_util from .const import CONF_PROCESS, PROCESS_ERRORS +from .util import get_all_pressure_info if TYPE_CHECKING: from . import SystemMonitorConfigEntry @@ -40,6 +41,7 @@ class SensorData: io_counters: dict[str, snetio] load: tuple[float, float, float] memory: VirtualMemory + pressure: dict[str, Any] process_fds: dict[str, int] processes: list[Process] swap: sswap @@ -73,6 +75,7 @@ def as_dict(self) -> dict[str, Any]: "io_counters": io_counters, "load": str(self.load), "memory": str(self.memory), + "pressure": self.pressure, "process_fds": self.process_fds, "processes": str(self.processes), "swap": str(self.swap), @@ -141,6 +144,7 @@ def set_subscribers_tuples( ("io_counters", ""): set(), ("load", ""): set(), ("memory", ""): set(), + ("pressure", ""): set(), ("processes", ""): set(), ("swap", ""): set(), ("temperatures", ""): set(), @@ -173,6 +177,7 @@ async def _async_update_data(self) -> SensorData: io_counters=_data["io_counters"], load=load, memory=_data["memory"], + pressure=_data["pressure"], process_fds=_data["process_fds"], processes=_data["processes"], swap=_data["swap"], @@ -289,6 +294,11 @@ def update_data(self) -> dict[str, Any]: except AttributeError: _LOGGER.debug("OS does not provide battery sensors") + pressure: dict[str, Any] = {} + if self.update_subscribers[("pressure", "")] or self._initial_update: + pressure = get_all_pressure_info() + _LOGGER.debug("pressure: %s", pressure) + return { "addresses": addresses, "battery": battery, @@ -297,6 +307,7 @@ def update_data(self) -> dict[str, Any]: "fan_speed": fan_speed, "io_counters": io_counters, "memory": memory, + "pressure": pressure, "process_fds": process_fds, "processes": selected_processes, "swap": swap, diff --git a/homeassistant/components/systemmonitor/icons.json b/homeassistant/components/systemmonitor/icons.json index 7e8807917379a0..509316531a8ec6 100644 --- a/homeassistant/components/systemmonitor/icons.json +++ b/homeassistant/components/systemmonitor/icons.json @@ -4,6 +4,18 @@ "battery_empty": { "default": "mdi:battery-clock" }, + "cpu_pressure_some_avg10": { + "default": "mdi:gauge" + }, + "cpu_pressure_some_avg300": { + "default": "mdi:gauge" + }, + "cpu_pressure_some_avg60": { + "default": "mdi:gauge" + }, + "cpu_pressure_some_total": { + "default": "mdi:timer-outline" + }, "disk_free": { "default": "mdi:harddisk" }, @@ -16,6 +28,30 @@ "fan_speed": { "default": "mdi:fan" }, + "io_pressure_full_avg10": { + "default": "mdi:gauge" + }, + "io_pressure_full_avg300": { + "default": "mdi:gauge" + }, + "io_pressure_full_avg60": { + "default": "mdi:gauge" + }, + "io_pressure_full_total": { + "default": "mdi:timer-outline" + }, + "io_pressure_some_avg10": { + "default": "mdi:gauge" + }, + "io_pressure_some_avg300": { + "default": "mdi:gauge" + }, + "io_pressure_some_avg60": { + "default": "mdi:gauge" + }, + "io_pressure_some_total": { + "default": "mdi:timer-outline" + }, "ipv4_address": { "default": "mdi:ip-network" }, @@ -25,6 +61,30 @@ "memory_free": { "default": "mdi:memory" }, + "memory_pressure_full_avg10": { + "default": "mdi:gauge" + }, + "memory_pressure_full_avg300": { + "default": "mdi:gauge" + }, + "memory_pressure_full_avg60": { + "default": "mdi:gauge" + }, + "memory_pressure_full_total": { + "default": "mdi:timer-outline" + }, + "memory_pressure_some_avg10": { + "default": "mdi:gauge" + }, + "memory_pressure_some_avg300": { + "default": "mdi:gauge" + }, + "memory_pressure_some_avg60": { + "default": "mdi:gauge" + }, + "memory_pressure_some_total": { + "default": "mdi:timer-outline" + }, "memory_use": { "default": "mdi:memory" }, diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 33b8e2093cf6d3..fe57ada5318a35 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -30,6 +30,7 @@ UnitOfDataRate, UnitOfInformation, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er @@ -68,7 +69,11 @@ "memory_", "processor_use", "swap_", + "memory_pressure_", + "io_pressure_", + "cpu_pressure_", ) + SENSORS_WITH_ARG = { "disk_": "disk_arguments", "fan_speed": "fan_speed_arguments", @@ -469,6 +474,224 @@ class SysMonitorSensorEntityDescription(SensorEntityDescription): value_fn=get_throughput, add_to_update=lambda entity: ("io_counters", ""), ), + "memory_pressure_some_avg10": SysMonitorSensorEntityDescription( + key="memory_pressure_some_avg10", + translation_key="memory_pressure_some_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("some", {}) + .get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_some_avg60": SysMonitorSensorEntityDescription( + key="memory_pressure_some_avg60", + translation_key="memory_pressure_some_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("some", {}) + .get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_some_avg300": SysMonitorSensorEntityDescription( + key="memory_pressure_some_avg300", + translation_key="memory_pressure_some_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("some", {}) + .get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_some_total": SysMonitorSensorEntityDescription( + key="memory_pressure_some_total", + translation_key="memory_pressure_some_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("some", {}) + .get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_full_avg10": SysMonitorSensorEntityDescription( + key="memory_pressure_full_avg10", + translation_key="memory_pressure_full_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("full", {}) + .get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_full_avg60": SysMonitorSensorEntityDescription( + key="memory_pressure_full_avg60", + translation_key="memory_pressure_full_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("full", {}) + .get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_full_avg300": SysMonitorSensorEntityDescription( + key="memory_pressure_full_avg300", + translation_key="memory_pressure_full_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("full", {}) + .get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "memory_pressure_full_total": SysMonitorSensorEntityDescription( + key="memory_pressure_full_total", + translation_key="memory_pressure_full_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("memory", {}) + .get("full", {}) + .get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_some_avg10": SysMonitorSensorEntityDescription( + key="io_pressure_some_avg10", + translation_key="io_pressure_some_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("some", {}).get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_some_avg60": SysMonitorSensorEntityDescription( + key="io_pressure_some_avg60", + translation_key="io_pressure_some_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("some", {}).get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_some_avg300": SysMonitorSensorEntityDescription( + key="io_pressure_some_avg300", + translation_key="io_pressure_some_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("some", {}).get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_some_total": SysMonitorSensorEntityDescription( + key="io_pressure_some_total", + translation_key="io_pressure_some_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("some", {}).get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_full_avg10": SysMonitorSensorEntityDescription( + key="io_pressure_full_avg10", + translation_key="io_pressure_full_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("full", {}).get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_full_avg60": SysMonitorSensorEntityDescription( + key="io_pressure_full_avg60", + translation_key="io_pressure_full_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("full", {}).get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_full_avg300": SysMonitorSensorEntityDescription( + key="io_pressure_full_avg300", + translation_key="io_pressure_full_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("full", {}).get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "io_pressure_full_total": SysMonitorSensorEntityDescription( + key="io_pressure_full_total", + translation_key="io_pressure_full_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("io", {}).get("full", {}).get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "cpu_pressure_some_avg10": SysMonitorSensorEntityDescription( + key="cpu_pressure_some_avg10", + translation_key="cpu_pressure_some_avg10", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("cpu", {}).get("some", {}).get("avg10") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "cpu_pressure_some_avg60": SysMonitorSensorEntityDescription( + key="cpu_pressure_some_avg60", + translation_key="cpu_pressure_some_avg60", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("cpu", {}).get("some", {}).get("avg60") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "cpu_pressure_some_avg300": SysMonitorSensorEntityDescription( + key="cpu_pressure_some_avg300", + translation_key="cpu_pressure_some_avg300", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("cpu", {}) + .get("some", {}) + .get("avg300") + ), + add_to_update=lambda entity: ("pressure", ""), + ), + "cpu_pressure_some_total": SysMonitorSensorEntityDescription( + key="cpu_pressure_some_total", + translation_key="cpu_pressure_some_total", + native_unit_of_measurement=UnitOfTime.MICROSECONDS, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda entity: ( + entity.coordinator.data.pressure.get("cpu", {}).get("some", {}).get("total") + ), + add_to_update=lambda entity: ("pressure", ""), + ), } diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json index 38d0b63dc51066..6041dc02c08388 100644 --- a/homeassistant/components/systemmonitor/strings.json +++ b/homeassistant/components/systemmonitor/strings.json @@ -19,6 +19,18 @@ "battery_empty": { "name": "Battery empty" }, + "cpu_pressure_some_avg10": { + "name": "CPU pressure some 10s average" + }, + "cpu_pressure_some_avg300": { + "name": "CPU pressure some 300s average" + }, + "cpu_pressure_some_avg60": { + "name": "CPU pressure some 60s average" + }, + "cpu_pressure_some_total": { + "name": "CPU pressure some total" + }, "disk_free": { "name": "Disk free {mount_point}" }, @@ -31,6 +43,30 @@ "fan_speed": { "name": "{fan_name} fan speed" }, + "io_pressure_full_avg10": { + "name": "IO pressure full 10s average" + }, + "io_pressure_full_avg300": { + "name": "IO pressure full 300s average" + }, + "io_pressure_full_avg60": { + "name": "IO pressure full 60s average" + }, + "io_pressure_full_total": { + "name": "IO pressure full total" + }, + "io_pressure_some_avg10": { + "name": "IO pressure some 10s average" + }, + "io_pressure_some_avg300": { + "name": "IO pressure some 300s average" + }, + "io_pressure_some_avg60": { + "name": "IO pressure some 60s average" + }, + "io_pressure_some_total": { + "name": "IO pressure some total" + }, "ipv4_address": { "name": "IPv4 address {ip_address}" }, @@ -52,6 +88,30 @@ "memory_free": { "name": "Memory free" }, + "memory_pressure_full_avg10": { + "name": "Memory pressure full 10s average" + }, + "memory_pressure_full_avg300": { + "name": "Memory pressure full 300s average" + }, + "memory_pressure_full_avg60": { + "name": "Memory pressure full 60s average" + }, + "memory_pressure_full_total": { + "name": "Memory pressure full total" + }, + "memory_pressure_some_avg10": { + "name": "Memory pressure some 10s average" + }, + "memory_pressure_some_avg300": { + "name": "Memory pressure some 300s average" + }, + "memory_pressure_some_avg60": { + "name": "Memory pressure some 60s average" + }, + "memory_pressure_some_total": { + "name": "Memory pressure some total" + }, "memory_use": { "name": "Memory use" }, diff --git a/homeassistant/components/systemmonitor/util.py b/homeassistant/components/systemmonitor/util.py index 1118445dab122f..07790479c78672 100644 --- a/homeassistant/components/systemmonitor/util.py +++ b/homeassistant/components/systemmonitor/util.py @@ -2,6 +2,8 @@ import logging import os +import re +from typing import Any from psutil._common import sfan, shwtemp import psutil_home_assistant as ha_psutil @@ -105,3 +107,65 @@ def read_fan_speed(fans: dict[str, list[sfan]]) -> dict[str, int]: sensor_fans[_label] = round(entry.current, 0) return sensor_fans + + +def parse_pressure_file(file_path: str) -> dict[str, dict[str, float | int]] | None: + """Parses a single /proc/pressure file (cpu, memory, or io). + + Args: + file_path (str): The full path to the pressure file. + + Returns: + dict: A dictionary containing the parsed pressure stall information, + or None if the file cannot be read or parsed. + """ + try: + with open(file_path, encoding="utf-8") as f: + content = f.read() + except OSError: + return None + + data: dict[str, dict[str, float | int]] = {} + # The regex looks for 'some' and 'full' lines and captures the values. + # It accounts for floating point numbers and integer values. + # Example line: "some avg10=0.00 avg60=0.00 avg300=0.00 total=0" + pattern = re.compile(r"(some|full)\s+(.*)") + lines = content.strip().split("\n") + + for line in lines: + match = pattern.match(line) + if match: + line_type, values_str = match.groups() + values: dict[str, float | int] = {} + for item in values_str.split(): + try: + key, value = item.split("=") + # Convert values to float, except for 'total' which is an integer + if key == "total": + values[key] = int(value) + else: + values[key] = float(value) + except ValueError: + continue + data[line_type] = values + + return data + + +def get_all_pressure_info() -> dict[str, Any]: + """Parses all available pressure information from /proc/pressure/. + + Returns: + dict: A dictionary containing cpu, memory, and io pressure info. + Returns an empty dictionary if no pressure files are found. + """ + pressure_info: dict[str, Any] = {} + resources = ["cpu", "memory", "io"] + + for resource in resources: + file_path = f"/proc/pressure/{resource}" + parsed_data = parse_pressure_file(file_path) + if parsed_data: + pressure_info[resource] = parsed_data + + return pressure_info diff --git a/tests/components/systemmonitor/conftest.py b/tests/components/systemmonitor/conftest.py index 0dad6670587382..8b01b8e39ac85e 100644 --- a/tests/components/systemmonitor/conftest.py +++ b/tests/components/systemmonitor/conftest.py @@ -25,6 +25,18 @@ from tests.common import MockConfigEntry +MOCK_PRESSURE_INFO = { + "cpu": {"some": {"avg10": 1.1, "avg60": 2.2, "avg300": 3.3, "total": 12345}}, + "memory": { + "some": {"avg10": 4.4, "avg60": 5.5, "avg300": 6.6, "total": 54321}, + "full": {"avg10": 0.4, "avg60": 0.5, "avg300": 0.6, "total": 432}, + }, + "io": { + "some": {"avg10": 7.7, "avg60": 8.8, "avg300": 9.9, "total": 67890}, + "full": {"avg10": 0.7, "avg60": 0.8, "avg300": 0.9, "total": 789}, + }, +} + @pytest.fixture(autouse=True) def mock_sys_platform() -> Generator[None]: @@ -133,9 +145,15 @@ def mock_process() -> list[MockProcess]: @pytest.fixture def mock_psutil(mock_process: list[MockProcess]) -> Generator: """Mock psutil.""" - with patch( - "homeassistant.components.systemmonitor.ha_psutil.PsutilWrapper", - ) as psutil_wrapper: + with ( + patch( + "homeassistant.components.systemmonitor.ha_psutil.PsutilWrapper", + ) as psutil_wrapper, + patch( + "homeassistant.components.systemmonitor.coordinator.get_all_pressure_info", + return_value=MOCK_PRESSURE_INFO, + ), + ): _wrapper = psutil_wrapper.return_value _wrapper.psutil = NonCallableMock() mock_psutil = _wrapper.psutil diff --git a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr index 74d8a797678b05..cc54dfa9326af7 100644 --- a/tests/components/systemmonitor/snapshots/test_diagnostics.ambr +++ b/tests/components/systemmonitor/snapshots/test_diagnostics.ambr @@ -27,6 +27,44 @@ }), 'load': '(1, 2, 3)', 'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)', + 'pressure': dict({ + 'cpu': dict({ + 'some': dict({ + 'avg10': 1.1, + 'avg300': 3.3, + 'avg60': 2.2, + 'total': 12345, + }), + }), + 'io': dict({ + 'full': dict({ + 'avg10': 0.7, + 'avg300': 0.9, + 'avg60': 0.8, + 'total': 789, + }), + 'some': dict({ + 'avg10': 7.7, + 'avg300': 9.9, + 'avg60': 8.8, + 'total': 67890, + }), + }), + 'memory': dict({ + 'full': dict({ + 'avg10': 0.4, + 'avg300': 0.6, + 'avg60': 0.5, + 'total': 432, + }), + 'some': dict({ + 'avg10': 4.4, + 'avg300': 6.6, + 'avg60': 5.5, + 'total': 54321, + }), + }), + }), 'process_fds': dict({ 'pip': 15, 'python3': 42, @@ -93,6 +131,44 @@ 'io_counters': None, 'load': '(1, 2, 3)', 'memory': 'VirtualMemory(total=104857600, available=41943040, percent=40.0, used=62914560, free=31457280)', + 'pressure': dict({ + 'cpu': dict({ + 'some': dict({ + 'avg10': 1.1, + 'avg300': 3.3, + 'avg60': 2.2, + 'total': 12345, + }), + }), + 'io': dict({ + 'full': dict({ + 'avg10': 0.7, + 'avg300': 0.9, + 'avg60': 0.8, + 'total': 789, + }), + 'some': dict({ + 'avg10': 7.7, + 'avg300': 9.9, + 'avg60': 8.8, + 'total': 67890, + }), + }), + 'memory': dict({ + 'full': dict({ + 'avg10': 0.4, + 'avg300': 0.6, + 'avg60': 0.5, + 'total': 432, + }), + 'some': dict({ + 'avg10': 4.4, + 'avg300': 6.6, + 'avg60': 5.5, + 'total': 54321, + }), + }), + }), 'process_fds': dict({ 'pip': 15, 'python3': 42, diff --git a/tests/components/systemmonitor/snapshots/test_sensor.ambr b/tests/components/systemmonitor/snapshots/test_sensor.ambr index 9d54f7bfc2af15..4bcc0010f242c9 100644 --- a/tests/components/systemmonitor/snapshots/test_sensor.ambr +++ b/tests/components/systemmonitor/snapshots/test_sensor.ambr @@ -1,5 +1,15 @@ # serializer version: 1 -# name: test_sensor[System Monitor Battery - attributes] +# name: test_sensor[sensor.system_monitor_another_fan_fan_speed - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor another-fan fan speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor[sensor.system_monitor_another_fan_fan_speed - state] + '1300' +# --- +# name: test_sensor[sensor.system_monitor_battery - attributes] ReadOnlyDict({ 'device_class': 'battery', 'friendly_name': 'System Monitor Battery', @@ -7,20 +17,30 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Battery - state] +# name: test_sensor[sensor.system_monitor_battery - state] '93' # --- -# name: test_sensor[System Monitor Battery empty - attributes] +# name: test_sensor[sensor.system_monitor_battery_empty - attributes] ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'System Monitor Battery empty', 'state_class': , }) # --- -# name: test_sensor[System Monitor Battery empty - state] +# name: test_sensor[sensor.system_monitor_battery_empty - state] '2024-02-24T19:37:00+00:00' # --- -# name: test_sensor[System Monitor Disk free / - attributes] +# name: test_sensor[sensor.system_monitor_cpu_fan_fan_speed - attributes] + ReadOnlyDict({ + 'friendly_name': 'System Monitor cpu-fan fan speed', + 'state_class': , + 'unit_of_measurement': 'rpm', + }) +# --- +# name: test_sensor[sensor.system_monitor_cpu_fan_fan_speed - state] + '1200' +# --- +# name: test_sensor[sensor.system_monitor_disk_free - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Disk free /', @@ -28,10 +48,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Disk free / - state] +# name: test_sensor[sensor.system_monitor_disk_free - state] '200.0' # --- -# name: test_sensor[System Monitor Disk free /media/share - attributes] +# name: test_sensor[sensor.system_monitor_disk_free_media_share - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Disk free /media/share', @@ -39,40 +59,40 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Disk free /media/share - state] +# name: test_sensor[sensor.system_monitor_disk_free_media_share - state] '200.0' # --- -# name: test_sensor[System Monitor Disk usage / - attributes] +# name: test_sensor[sensor.system_monitor_disk_usage - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Disk usage /', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Disk usage / - state] +# name: test_sensor[sensor.system_monitor_disk_usage - state] '60.0' # --- -# name: test_sensor[System Monitor Disk usage /home/notexist/ - attributes] +# name: test_sensor[sensor.system_monitor_disk_usage_home_notexist - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Disk usage /home/notexist/', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Disk usage /home/notexist/ - state] +# name: test_sensor[sensor.system_monitor_disk_usage_home_notexist - state] '60.0' # --- -# name: test_sensor[System Monitor Disk usage /media/share - attributes] +# name: test_sensor[sensor.system_monitor_disk_usage_media_share - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Disk usage /media/share', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Disk usage /media/share - state] +# name: test_sensor[sensor.system_monitor_disk_usage_media_share - state] '60.0' # --- -# name: test_sensor[System Monitor Disk use / - attributes] +# name: test_sensor[sensor.system_monitor_disk_use - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Disk use /', @@ -80,10 +100,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Disk use / - state] +# name: test_sensor[sensor.system_monitor_disk_use - state] '300.0' # --- -# name: test_sensor[System Monitor Disk use /media/share - attributes] +# name: test_sensor[sensor.system_monitor_disk_use_media_share - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Disk use /media/share', @@ -91,81 +111,81 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Disk use /media/share - state] +# name: test_sensor[sensor.system_monitor_disk_use_media_share - state] '300.0' # --- -# name: test_sensor[System Monitor IPv4 address eth0 - attributes] +# name: test_sensor[sensor.system_monitor_ipv4_address_eth0 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor IPv4 address eth0', }) # --- -# name: test_sensor[System Monitor IPv4 address eth0 - state] +# name: test_sensor[sensor.system_monitor_ipv4_address_eth0 - state] '192.168.1.1' # --- -# name: test_sensor[System Monitor IPv4 address eth1 - attributes] +# name: test_sensor[sensor.system_monitor_ipv4_address_eth1 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor IPv4 address eth1', }) # --- -# name: test_sensor[System Monitor IPv4 address eth1 - state] +# name: test_sensor[sensor.system_monitor_ipv4_address_eth1 - state] '192.168.10.1' # --- -# name: test_sensor[System Monitor IPv6 address eth0 - attributes] +# name: test_sensor[sensor.system_monitor_ipv6_address_eth0 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor IPv6 address eth0', }) # --- -# name: test_sensor[System Monitor IPv6 address eth0 - state] +# name: test_sensor[sensor.system_monitor_ipv6_address_eth0 - state] '2a00:1f:2103:3a01:3333:2222:1111:0000' # --- -# name: test_sensor[System Monitor IPv6 address eth1 - attributes] +# name: test_sensor[sensor.system_monitor_ipv6_address_eth1 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor IPv6 address eth1', }) # --- -# name: test_sensor[System Monitor IPv6 address eth1 - state] +# name: test_sensor[sensor.system_monitor_ipv6_address_eth1 - state] 'unknown' # --- -# name: test_sensor[System Monitor Last boot - attributes] +# name: test_sensor[sensor.system_monitor_last_boot - attributes] ReadOnlyDict({ 'device_class': 'timestamp', 'friendly_name': 'System Monitor Last boot', }) # --- -# name: test_sensor[System Monitor Last boot - state] +# name: test_sensor[sensor.system_monitor_last_boot - state] '2024-02-24T15:00:00+00:00' # --- -# name: test_sensor[System Monitor Load (1 min) - attributes] +# name: test_sensor[sensor.system_monitor_load_15_min - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (1 min)', + 'friendly_name': 'System Monitor Load (15 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (1 min) - state] - '1' +# name: test_sensor[sensor.system_monitor_load_15_min - state] + '3' # --- -# name: test_sensor[System Monitor Load (15 min) - attributes] +# name: test_sensor[sensor.system_monitor_load_1_min - attributes] ReadOnlyDict({ - 'friendly_name': 'System Monitor Load (15 min)', + 'friendly_name': 'System Monitor Load (1 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (15 min) - state] - '3' +# name: test_sensor[sensor.system_monitor_load_1_min - state] + '1' # --- -# name: test_sensor[System Monitor Load (5 min) - attributes] +# name: test_sensor[sensor.system_monitor_load_5_min - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Load (5 min)', 'icon': 'mdi:cpu-64-bit', 'state_class': , }) # --- -# name: test_sensor[System Monitor Load (5 min) - state] +# name: test_sensor[sensor.system_monitor_load_5_min - state] '2' # --- -# name: test_sensor[System Monitor Memory free - attributes] +# name: test_sensor[sensor.system_monitor_memory_free - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Memory free', @@ -173,20 +193,20 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Memory free - state] +# name: test_sensor[sensor.system_monitor_memory_free - state] '40.0' # --- -# name: test_sensor[System Monitor Memory usage - attributes] +# name: test_sensor[sensor.system_monitor_memory_usage - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Memory usage', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Memory usage - state] +# name: test_sensor[sensor.system_monitor_memory_usage - state] '40.0' # --- -# name: test_sensor[System Monitor Memory use - attributes] +# name: test_sensor[sensor.system_monitor_memory_use - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Memory use', @@ -194,10 +214,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Memory use - state] +# name: test_sensor[sensor.system_monitor_memory_use - state] '60.0' # --- -# name: test_sensor[System Monitor Network in eth0 - attributes] +# name: test_sensor[sensor.system_monitor_network_in_eth0 - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Network in eth0', @@ -205,10 +225,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Network in eth0 - state] +# name: test_sensor[sensor.system_monitor_network_in_eth0 - state] '100.0' # --- -# name: test_sensor[System Monitor Network in eth1 - attributes] +# name: test_sensor[sensor.system_monitor_network_in_eth1 - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Network in eth1', @@ -216,10 +236,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Network in eth1 - state] +# name: test_sensor[sensor.system_monitor_network_in_eth1 - state] '200.0' # --- -# name: test_sensor[System Monitor Network out eth0 - attributes] +# name: test_sensor[sensor.system_monitor_network_out_eth0 - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Network out eth0', @@ -227,10 +247,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Network out eth0 - state] +# name: test_sensor[sensor.system_monitor_network_out_eth0 - state] '100.0' # --- -# name: test_sensor[System Monitor Network out eth1 - attributes] +# name: test_sensor[sensor.system_monitor_network_out_eth1 - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Network out eth1', @@ -238,10 +258,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Network out eth1 - state] +# name: test_sensor[sensor.system_monitor_network_out_eth1 - state] '200.0' # --- -# name: test_sensor[System Monitor Network throughput in eth0 - attributes] +# name: test_sensor[sensor.system_monitor_network_throughput_in_eth0 - attributes] ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'System Monitor Network throughput in eth0', @@ -249,10 +269,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Network throughput in eth0 - state] +# name: test_sensor[sensor.system_monitor_network_throughput_in_eth0 - state] 'unknown' # --- -# name: test_sensor[System Monitor Network throughput in eth1 - attributes] +# name: test_sensor[sensor.system_monitor_network_throughput_in_eth1 - attributes] ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'System Monitor Network throughput in eth1', @@ -260,10 +280,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Network throughput in eth1 - state] +# name: test_sensor[sensor.system_monitor_network_throughput_in_eth1 - state] 'unknown' # --- -# name: test_sensor[System Monitor Network throughput out eth0 - attributes] +# name: test_sensor[sensor.system_monitor_network_throughput_out_eth0 - attributes] ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'System Monitor Network throughput out eth0', @@ -271,10 +291,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Network throughput out eth0 - state] +# name: test_sensor[sensor.system_monitor_network_throughput_out_eth0 - state] 'unknown' # --- -# name: test_sensor[System Monitor Network throughput out eth1 - attributes] +# name: test_sensor[sensor.system_monitor_network_throughput_out_eth1 - attributes] ReadOnlyDict({ 'device_class': 'data_rate', 'friendly_name': 'System Monitor Network throughput out eth1', @@ -282,64 +302,64 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Network throughput out eth1 - state] +# name: test_sensor[sensor.system_monitor_network_throughput_out_eth1 - state] 'unknown' # --- -# name: test_sensor[System Monitor Open file descriptors pip - attributes] +# name: test_sensor[sensor.system_monitor_open_file_descriptors_pip - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Open file descriptors pip', 'state_class': , }) # --- -# name: test_sensor[System Monitor Open file descriptors pip - state] +# name: test_sensor[sensor.system_monitor_open_file_descriptors_pip - state] '15' # --- -# name: test_sensor[System Monitor Open file descriptors python3 - attributes] +# name: test_sensor[sensor.system_monitor_open_file_descriptors_python3 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Open file descriptors python3', 'state_class': , }) # --- -# name: test_sensor[System Monitor Open file descriptors python3 - state] +# name: test_sensor[sensor.system_monitor_open_file_descriptors_python3 - state] '42' # --- -# name: test_sensor[System Monitor Packets in eth0 - attributes] +# name: test_sensor[sensor.system_monitor_packets_in_eth0 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Packets in eth0', 'state_class': , }) # --- -# name: test_sensor[System Monitor Packets in eth0 - state] +# name: test_sensor[sensor.system_monitor_packets_in_eth0 - state] '50' # --- -# name: test_sensor[System Monitor Packets in eth1 - attributes] +# name: test_sensor[sensor.system_monitor_packets_in_eth1 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Packets in eth1', 'state_class': , }) # --- -# name: test_sensor[System Monitor Packets in eth1 - state] +# name: test_sensor[sensor.system_monitor_packets_in_eth1 - state] '150' # --- -# name: test_sensor[System Monitor Packets out eth0 - attributes] +# name: test_sensor[sensor.system_monitor_packets_out_eth0 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Packets out eth0', 'state_class': , }) # --- -# name: test_sensor[System Monitor Packets out eth0 - state] +# name: test_sensor[sensor.system_monitor_packets_out_eth0 - state] '50' # --- -# name: test_sensor[System Monitor Packets out eth1 - attributes] +# name: test_sensor[sensor.system_monitor_packets_out_eth1 - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Packets out eth1', 'state_class': , }) # --- -# name: test_sensor[System Monitor Packets out eth1 - state] +# name: test_sensor[sensor.system_monitor_packets_out_eth1 - state] '150' # --- -# name: test_sensor[System Monitor Processor temperature - attributes] +# name: test_sensor[sensor.system_monitor_processor_temperature - attributes] ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'System Monitor Processor temperature', @@ -347,10 +367,10 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Processor temperature - state] +# name: test_sensor[sensor.system_monitor_processor_temperature - state] '50.0' # --- -# name: test_sensor[System Monitor Processor use - attributes] +# name: test_sensor[sensor.system_monitor_processor_use - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Processor use', 'icon': 'mdi:cpu-64-bit', @@ -358,10 +378,10 @@ 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Processor use - state] +# name: test_sensor[sensor.system_monitor_processor_use - state] '10' # --- -# name: test_sensor[System Monitor Swap free - attributes] +# name: test_sensor[sensor.system_monitor_swap_free - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Swap free', @@ -369,20 +389,20 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Swap free - state] +# name: test_sensor[sensor.system_monitor_swap_free - state] '40.0' # --- -# name: test_sensor[System Monitor Swap usage - attributes] +# name: test_sensor[sensor.system_monitor_swap_usage - attributes] ReadOnlyDict({ 'friendly_name': 'System Monitor Swap usage', 'state_class': , 'unit_of_measurement': '%', }) # --- -# name: test_sensor[System Monitor Swap usage - state] +# name: test_sensor[sensor.system_monitor_swap_usage - state] '60.0' # --- -# name: test_sensor[System Monitor Swap use - attributes] +# name: test_sensor[sensor.system_monitor_swap_use - attributes] ReadOnlyDict({ 'device_class': 'data_size', 'friendly_name': 'System Monitor Swap use', @@ -390,26 +410,6 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[System Monitor Swap use - state] +# name: test_sensor[sensor.system_monitor_swap_use - state] '60.0' # --- -# name: test_sensor[System Monitor another-fan fan speed - attributes] - ReadOnlyDict({ - 'friendly_name': 'System Monitor another-fan fan speed', - 'state_class': , - 'unit_of_measurement': 'rpm', - }) -# --- -# name: test_sensor[System Monitor another-fan fan speed - state] - '1300' -# --- -# name: test_sensor[System Monitor cpu-fan fan speed - attributes] - ReadOnlyDict({ - 'friendly_name': 'System Monitor cpu-fan fan speed', - 'state_class': , - 'unit_of_measurement': 'rpm', - }) -# --- -# name: test_sensor[System Monitor cpu-fan fan speed - state] - '1200' -# --- diff --git a/tests/components/systemmonitor/test_sensor.py b/tests/components/systemmonitor/test_sensor.py index 575cb5fa3bd0f7..75cf03bb1f42a8 100644 --- a/tests/components/systemmonitor/test_sensor.py +++ b/tests/components/systemmonitor/test_sensor.py @@ -65,10 +65,61 @@ async def test_sensor( for entity in er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ): - if entity.domain == SENSOR_DOMAIN: + if entity.domain == SENSOR_DOMAIN and "pressure" not in entity.entity_id: state = hass.states.get(entity.entity_id) - assert state.state == snapshot(name=f"{state.name} - state") - assert state.attributes == snapshot(name=f"{state.name} - attributes") + assert state.state == snapshot(name=f"{entity.entity_id} - state") + assert state.attributes == snapshot(name=f"{entity.entity_id} - attributes") + + # Check PSI sensors explicitly as snapshots are not effective for them + # Check CPU pressure + state = hass.states.get("sensor.system_monitor_cpu_pressure_some_10s_average") + assert state.state == "1.1" + state = hass.states.get("sensor.system_monitor_cpu_pressure_some_60s_average") + assert state.state == "2.2" + state = hass.states.get("sensor.system_monitor_cpu_pressure_some_300s_average") + assert state.state == "3.3" + state = hass.states.get("sensor.system_monitor_cpu_pressure_some_total") + assert state.state == "12345" + + # Check Memory pressure some + state = hass.states.get("sensor.system_monitor_memory_pressure_some_10s_average") + assert state.state == "4.4" + state = hass.states.get("sensor.system_monitor_memory_pressure_some_60s_average") + assert state.state == "5.5" + state = hass.states.get("sensor.system_monitor_memory_pressure_some_300s_average") + assert state.state == "6.6" + state = hass.states.get("sensor.system_monitor_memory_pressure_some_total") + assert state.state == "54321" + + # Check Memory pressure full + state = hass.states.get("sensor.system_monitor_memory_pressure_full_10s_average") + assert state.state == "0.4" + state = hass.states.get("sensor.system_monitor_memory_pressure_full_60s_average") + assert state.state == "0.5" + state = hass.states.get("sensor.system_monitor_memory_pressure_full_300s_average") + assert state.state == "0.6" + state = hass.states.get("sensor.system_monitor_memory_pressure_full_total") + assert state.state == "432" + + # Check IO pressure some + state = hass.states.get("sensor.system_monitor_io_pressure_some_10s_average") + assert state.state == "7.7" + state = hass.states.get("sensor.system_monitor_io_pressure_some_60s_average") + assert state.state == "8.8" + state = hass.states.get("sensor.system_monitor_io_pressure_some_300s_average") + assert state.state == "9.9" + state = hass.states.get("sensor.system_monitor_io_pressure_some_total") + assert state.state == "67890" + + # Check IO pressure full + state = hass.states.get("sensor.system_monitor_io_pressure_full_10s_average") + assert state.state == "0.7" + state = hass.states.get("sensor.system_monitor_io_pressure_full_60s_average") + assert state.state == "0.8" + state = hass.states.get("sensor.system_monitor_io_pressure_full_300s_average") + assert state.state == "0.9" + state = hass.states.get("sensor.system_monitor_io_pressure_full_total") + assert state.state == "789" @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -582,7 +633,7 @@ async def test_remove_obsolete_entities( mock_added_config_entry.entry_id ) ) - == 44 + == 64 ) entity_registry.async_update_entity( @@ -625,7 +676,7 @@ async def test_remove_obsolete_entities( mock_added_config_entry.entry_id ) ) - == 45 + == 65 ) assert ( @@ -742,3 +793,36 @@ async def test_sensor_without_param_exception( assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_psi_sensor_unavailable( + hass: HomeAssistant, + mock_psutil: Mock, + mock_os: Mock, + entity_registry: er.EntityRegistry, +) -> None: + """Test the PSI sensor when data is unavailable.""" + mock_config_entry = MockConfigEntry( + title="System Monitor", + domain=DOMAIN, + data={}, + options={}, + ) + + with patch( + "homeassistant.components.systemmonitor.coordinator.get_all_pressure_info", + return_value={}, + ): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.system_monitor_cpu_pressure_some_10s_average") + assert state.state == STATE_UNKNOWN + state = hass.states.get( + "sensor.system_monitor_memory_pressure_full_60s_average" + ) + assert state.state == STATE_UNKNOWN + state = hass.states.get("sensor.system_monitor_io_pressure_some_total") + assert state.state == STATE_UNKNOWN