From 13527768cca2722c27056d015ab832322a8227ec Mon Sep 17 00:00:00 2001 From: Antoine Reversat Date: Thu, 9 Jan 2025 05:21:27 -0500 Subject: [PATCH] Add outside temperature sensor to fujitsu_fglair (#130717) --- .../components/fujitsu_fglair/__init__.py | 2 +- .../components/fujitsu_fglair/climate.py | 22 +--- .../components/fujitsu_fglair/entity.py | 33 ++++++ .../components/fujitsu_fglair/sensor.py | 47 ++++++++ .../components/fujitsu_fglair/strings.json | 7 ++ tests/components/fujitsu_fglair/conftest.py | 30 ++++- .../fujitsu_fglair/snapshots/test_sensor.ambr | 103 ++++++++++++++++++ .../components/fujitsu_fglair/test_climate.py | 19 +++- tests/components/fujitsu_fglair/test_init.py | 42 +------ .../components/fujitsu_fglair/test_sensor.py | 33 ++++++ 10 files changed, 272 insertions(+), 66 deletions(-) create mode 100644 homeassistant/components/fujitsu_fglair/entity.py create mode 100644 homeassistant/components/fujitsu_fglair/sensor.py create mode 100644 tests/components/fujitsu_fglair/snapshots/test_sensor.ambr create mode 100644 tests/components/fujitsu_fglair/test_sensor.py diff --git a/homeassistant/components/fujitsu_fglair/__init__.py b/homeassistant/components/fujitsu_fglair/__init__.py index f25e01bcd1172d..547545e4feb4f5 100644 --- a/homeassistant/components/fujitsu_fglair/__init__.py +++ b/homeassistant/components/fujitsu_fglair/__init__.py @@ -15,7 +15,7 @@ from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU from .coordinator import FGLairCoordinator -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR] type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py index 5359075c7280f7..c0f5ab7dce4e1c 100644 --- a/homeassistant/components/fujitsu_fglair/climate.py +++ b/homeassistant/components/fujitsu_fglair/climate.py @@ -25,13 +25,11 @@ ) from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import FGLairConfigEntry -from .const import DOMAIN from .coordinator import FGLairCoordinator +from .entity import FGLairEntity HA_TO_FUJI_FAN = { FAN_LOW: FanSpeed.LOW, @@ -72,28 +70,19 @@ async def async_setup_entry( ) -class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity): +class FGLairDevice(FGLairEntity, ClimateEntity): """Represent a Fujitsu HVAC device.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_precision = PRECISION_HALVES _attr_target_temperature_step = 0.5 - _attr_has_entity_name = True _attr_name = None def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: """Store the representation of the device and set the static attributes.""" - super().__init__(coordinator, context=device.device_serial_number) + super().__init__(coordinator, device) self._attr_unique_id = device.device_serial_number - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device.device_serial_number)}, - name=device.device_name, - manufacturer="Fujitsu", - model=device.property_values["model_name"], - serial_number=device.device_serial_number, - sw_version=device.property_values["mcu_firmware_version"], - ) self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE @@ -109,11 +98,6 @@ def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._set_attr() - @property - def device(self) -> FujitsuHVAC: - """Return the device object from the coordinator data.""" - return self.coordinator.data[self.coordinator_context] - @property def available(self) -> bool: """Return if the device is available.""" diff --git a/homeassistant/components/fujitsu_fglair/entity.py b/homeassistant/components/fujitsu_fglair/entity.py new file mode 100644 index 00000000000000..54d33d0e46325e --- /dev/null +++ b/homeassistant/components/fujitsu_fglair/entity.py @@ -0,0 +1,33 @@ +"""Fujitsu FGlair base entity.""" + +from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import FGLairCoordinator + + +class FGLairEntity(CoordinatorEntity[FGLairCoordinator]): + """Generic Fglair entity (base class).""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: + """Store the representation of the device.""" + super().__init__(coordinator, context=device.device_serial_number) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_serial_number)}, + name=device.device_name, + manufacturer="Fujitsu", + model=device.property_values["model_name"], + serial_number=device.device_serial_number, + sw_version=device.property_values["mcu_firmware_version"], + ) + + @property + def device(self) -> FujitsuHVAC: + """Return the device object from the coordinator data.""" + return self.coordinator.data[self.coordinator_context] diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py new file mode 100644 index 00000000000000..1426e2349ea74c --- /dev/null +++ b/homeassistant/components/fujitsu_fglair/sensor.py @@ -0,0 +1,47 @@ +"""Outside temperature sensor for Fujitsu FGlair HVAC systems.""" + +from ayla_iot_unofficial.fujitsu_hvac import FujitsuHVAC + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .climate import FGLairConfigEntry +from .coordinator import FGLairCoordinator +from .entity import FGLairEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: FGLairConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up one Fujitsu HVAC device.""" + async_add_entities( + FGLairOutsideTemperature(entry.runtime_data, device) + for device in entry.runtime_data.data.values() + ) + + +class FGLairOutsideTemperature(FGLairEntity, SensorEntity): + """Entity representing outside temperature sensed by the outside unit of a Fujitsu Heatpump.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_translation_key = "fglair_outside_temp" + + def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: + """Store the representation of the device.""" + super().__init__(coordinator, device) + self._attr_unique_id = f"{device.device_serial_number}_outside_temperature" + + @property + def native_value(self) -> float | None: + """Return the sensed outdoor temperature un celsius.""" + return self.device.outdoor_temperature # type: ignore[no-any-return] diff --git a/homeassistant/components/fujitsu_fglair/strings.json b/homeassistant/components/fujitsu_fglair/strings.json index 3ad4e59ec1cbe7..ea97ca416e5450 100644 --- a/homeassistant/components/fujitsu_fglair/strings.json +++ b/homeassistant/components/fujitsu_fglair/strings.json @@ -35,5 +35,12 @@ "cn": "China" } } + }, + "entity": { + "sensor": { + "fglair_outside_temp": { + "name": "Outside temperature" + } + } } } diff --git a/tests/components/fujitsu_fglair/conftest.py b/tests/components/fujitsu_fglair/conftest.py index 5974adbeb0db84..71a11557b44252 100644 --- a/tests/components/fujitsu_fglair/conftest.py +++ b/tests/components/fujitsu_fglair/conftest.py @@ -1,6 +1,6 @@ """Common fixtures for the Fujitsu HVAC (based on Ayla IOT) tests.""" -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator from unittest.mock import AsyncMock, create_autospec, patch from ayla_iot_unofficial import AylaApi @@ -12,7 +12,8 @@ DOMAIN, REGION_DEFAULT, ) -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -33,6 +34,12 @@ } +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" @@ -78,6 +85,24 @@ def mock_config_entry(request: pytest.FixtureRequest) -> MockConfigEntry: ) +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + platforms: list[Platform], + mock_config_entry: MockConfigEntry, +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + mock_config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch("homeassistant.components.fujitsu_fglair.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + def _create_device(serial_number: str) -> AsyncMock: dev = AsyncMock(spec=FujitsuHVAC) dev.device_serial_number = serial_number @@ -109,6 +134,7 @@ def _create_device(serial_number: str) -> AsyncMock: dev.temperature_range = [18.0, 26.0] dev.sensed_temp = 22.0 dev.set_temp = 21.0 + dev.outdoor_temperature = 5.0 return dev diff --git a/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..89738cc4a66d6f --- /dev/null +++ b/tests/components/fujitsu_fglair/snapshots/test_sensor.ambr @@ -0,0 +1,103 @@ +# serializer version: 1 +# name: test_entities[sensor.testserial123_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial123_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial123_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial123_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial123 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial123_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testserial345_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'fujitsu_fglair', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'fglair_outside_temp', + 'unique_id': 'testserial345_outside_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.testserial345_outside_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'testserial345 Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.testserial345_outside_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.0', + }) +# --- diff --git a/tests/components/fujitsu_fglair/test_climate.py b/tests/components/fujitsu_fglair/test_climate.py index daddc83a871963..676ff97f26a3b3 100644 --- a/tests/components/fujitsu_fglair/test_climate.py +++ b/tests/components/fujitsu_fglair/test_climate.py @@ -1,7 +1,9 @@ """Test for the climate entities of Fujitsu HVAC.""" +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock +import pytest from syrupy import SnapshotAssertion from homeassistant.components.climate import ( @@ -23,24 +25,32 @@ HA_TO_FUJI_HVAC, HA_TO_FUJI_SWING, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import entity_id, setup_integration +from . import entity_id from tests.common import MockConfigEntry, snapshot_platform +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.CLIMATE] + + async def test_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, mock_ayla_api: AsyncMock, mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that coordinator returns the data we expect after the first refresh.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) @@ -51,9 +61,10 @@ async def test_set_attributes( mock_ayla_api: AsyncMock, mock_devices: list[AsyncMock], mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], ) -> None: """Test that setting the attributes calls the correct functions on the device.""" - await setup_integration(hass, mock_config_entry) + assert await integration_setup() await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/fujitsu_fglair/test_init.py b/tests/components/fujitsu_fglair/test_init.py index af51b222c191ad..d400d85c33ad8b 100644 --- a/tests/components/fujitsu_fglair/test_init.py +++ b/tests/components/fujitsu_fglair/test_init.py @@ -17,14 +17,9 @@ REGION_EU, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - STATE_UNAVAILABLE, - Platform, -) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, entity_registry as er +from homeassistant.helpers import aiohttp_client from . import entity_id, setup_integration from .conftest import TEST_PASSWORD, TEST_USERNAME @@ -166,36 +161,3 @@ async def test_startup_exception( await setup_integration(hass, mock_config_entry) assert len(hass.states.async_all()) == 0 - - -async def test_one_device_disabled( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - freezer: FrozenDateTimeFactory, - mock_devices: list[AsyncMock], - mock_ayla_api: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test that coordinator only updates devices that are currently listening.""" - await setup_integration(hass, mock_config_entry) - - for d in mock_devices: - d.async_update.assert_called_once() - d.reset_mock() - - entity = entity_registry.async_get( - entity_registry.async_get_entity_id( - Platform.CLIMATE, DOMAIN, mock_devices[0].device_serial_number - ) - ) - entity_registry.async_update_entity( - entity.entity_id, disabled_by=er.RegistryEntryDisabler.USER - ) - await hass.async_block_till_done() - freezer.tick(API_REFRESH) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - assert len(hass.states.async_all()) == len(mock_devices) - 1 - mock_devices[0].async_update.assert_not_called() - mock_devices[1].async_update.assert_called_once() diff --git a/tests/components/fujitsu_fglair/test_sensor.py b/tests/components/fujitsu_fglair/test_sensor.py new file mode 100644 index 00000000000000..e3f6109a2e87b1 --- /dev/null +++ b/tests/components/fujitsu_fglair/test_sensor.py @@ -0,0 +1,33 @@ +"""Test for the sensor platform entity of the fujitsu_fglair component.""" + +from collections.abc import Awaitable, Callable +from unittest.mock import AsyncMock + +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +async def test_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_ayla_api: AsyncMock, + mock_config_entry: MockConfigEntry, + integration_setup: Callable[[], Awaitable[bool]], +) -> None: + """Test that coordinator returns the data we expect after the first refresh.""" + assert await integration_setup() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)