Add outside temperature sensor to fujitsu_fglair (#130717)

This commit is contained in:
Antoine Reversat 2025-01-09 05:21:27 -05:00 committed by GitHub
parent 071e675d9d
commit 13527768cc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 272 additions and 66 deletions

View File

@ -15,7 +15,7 @@ from homeassistant.helpers import aiohttp_client
from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU from .const import API_TIMEOUT, CONF_EUROPE, CONF_REGION, REGION_DEFAULT, REGION_EU
from .coordinator import FGLairCoordinator from .coordinator import FGLairCoordinator
PLATFORMS: list[Platform] = [Platform.CLIMATE] PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR]
type FGLairConfigEntry = ConfigEntry[FGLairCoordinator] type FGLairConfigEntry = ConfigEntry[FGLairCoordinator]

View File

@ -25,13 +25,11 @@ from homeassistant.components.climate import (
) )
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FGLairConfigEntry from . import FGLairConfigEntry
from .const import DOMAIN
from .coordinator import FGLairCoordinator from .coordinator import FGLairCoordinator
from .entity import FGLairEntity
HA_TO_FUJI_FAN = { HA_TO_FUJI_FAN = {
FAN_LOW: FanSpeed.LOW, 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.""" """Represent a Fujitsu HVAC device."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_precision = PRECISION_HALVES _attr_precision = PRECISION_HALVES
_attr_target_temperature_step = 0.5 _attr_target_temperature_step = 0.5
_attr_has_entity_name = True
_attr_name = None _attr_name = None
def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None: def __init__(self, coordinator: FGLairCoordinator, device: FujitsuHVAC) -> None:
"""Store the representation of the device and set the static attributes.""" """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_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 = ( self._attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE ClimateEntityFeature.TARGET_TEMPERATURE
@ -109,11 +98,6 @@ class FGLairDevice(CoordinatorEntity[FGLairCoordinator], ClimateEntity):
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
self._set_attr() self._set_attr()
@property
def device(self) -> FujitsuHVAC:
"""Return the device object from the coordinator data."""
return self.coordinator.data[self.coordinator_context]
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return if the device is available.""" """Return if the device is available."""

View File

@ -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]

View File

@ -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]

View File

@ -35,5 +35,12 @@
"cn": "China" "cn": "China"
} }
} }
},
"entity": {
"sensor": {
"fglair_outside_temp": {
"name": "Outside temperature"
}
}
} }
} }

View File

@ -1,6 +1,6 @@
"""Common fixtures for the Fujitsu HVAC (based on Ayla IOT) tests.""" """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 unittest.mock import AsyncMock, create_autospec, patch
from ayla_iot_unofficial import AylaApi from ayla_iot_unofficial import AylaApi
@ -12,7 +12,8 @@ from homeassistant.components.fujitsu_fglair.const import (
DOMAIN, DOMAIN,
REGION_DEFAULT, 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 from tests.common import MockConfigEntry
@ -33,6 +34,12 @@ TEST_PROPERTY_VALUES = {
} }
@pytest.fixture
def platforms() -> list[Platform]:
"""Fixture to specify platforms to test."""
return []
@pytest.fixture @pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]: def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry.""" """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: def _create_device(serial_number: str) -> AsyncMock:
dev = AsyncMock(spec=FujitsuHVAC) dev = AsyncMock(spec=FujitsuHVAC)
dev.device_serial_number = serial_number 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.temperature_range = [18.0, 26.0]
dev.sensed_temp = 22.0 dev.sensed_temp = 22.0
dev.set_temp = 21.0 dev.set_temp = 21.0
dev.outdoor_temperature = 5.0
return dev return dev

View File

@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.testserial123_outside_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_entities[sensor.testserial123_outside_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'testserial123 Outside temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.testserial123_outside_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---
# name: test_entities[sensor.testserial345_outside_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.testserial345_outside_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'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': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_entities[sensor.testserial345_outside_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'testserial345 Outside temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.testserial345_outside_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.0',
})
# ---

View File

@ -1,7 +1,9 @@
"""Test for the climate entities of Fujitsu HVAC.""" """Test for the climate entities of Fujitsu HVAC."""
from collections.abc import Awaitable, Callable
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
import pytest
from syrupy import SnapshotAssertion from syrupy import SnapshotAssertion
from homeassistant.components.climate import ( from homeassistant.components.climate import (
@ -23,24 +25,32 @@ from homeassistant.components.fujitsu_fglair.climate import (
HA_TO_FUJI_HVAC, HA_TO_FUJI_HVAC,
HA_TO_FUJI_SWING, 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.core import HomeAssistant
from homeassistant.helpers import entity_registry as er 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 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( async def test_entities(
hass: HomeAssistant, hass: HomeAssistant,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion, snapshot: SnapshotAssertion,
mock_ayla_api: AsyncMock, mock_ayla_api: AsyncMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
) -> None: ) -> None:
"""Test that coordinator returns the data we expect after the first refresh.""" """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) 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_ayla_api: AsyncMock,
mock_devices: list[AsyncMock], mock_devices: list[AsyncMock],
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
) -> None: ) -> None:
"""Test that setting the attributes calls the correct functions on the device.""" """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( await hass.services.async_call(
CLIMATE_DOMAIN, CLIMATE_DOMAIN,

View File

@ -17,14 +17,9 @@ from homeassistant.components.fujitsu_fglair.const import (
REGION_EU, REGION_EU,
) )
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_UNAVAILABLE
CONF_PASSWORD,
CONF_USERNAME,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant 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 . import entity_id, setup_integration
from .conftest import TEST_PASSWORD, TEST_USERNAME from .conftest import TEST_PASSWORD, TEST_USERNAME
@ -166,36 +161,3 @@ async def test_startup_exception(
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
assert len(hass.states.async_all()) == 0 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()

View File

@ -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)